1use 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#[cfg_attr(feature = "typescript", derive(Tsify))]
16#[derive(Serialize, Deserialize, Debug, Clone)]
17pub struct Fit {
18 #[cfg_attr(feature = "typescript", tsify(optional))]
20 pub name: Option<String>,
21 pub ship: Ship,
23 pub items: Vec<FitItem>,
25 #[serde(default)]
27 pub character: Character,
28 #[serde(default)]
30 pub environment: Environment,
31 #[serde(default, skip_serializing_if = "Projection::is_empty")]
33 pub incoming: Projection,
34}
35
36#[cfg_attr(feature = "typescript", derive(Tsify))]
38#[derive(Serialize, Deserialize, Debug, Clone)]
39pub struct Ship {
40 pub type_id: i32,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub mode: Option<i32>,
45}
46
47#[cfg_attr(feature = "typescript", derive(Tsify))]
49#[derive(Serialize, Deserialize, Debug, Clone)]
50pub struct FitItem {
51 pub type_id: i32,
53 pub slot: Slot,
55 #[serde(default = "one")]
58 pub quantity: u32,
59 pub state: State,
62 #[cfg_attr(feature = "typescript", tsify(optional))]
64 pub charge: Option<Charge>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub mutation: Option<Mutation>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub fighter_abilities: Option<BTreeSet<i32>>,
72 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
75 pub booster_side_effects: BTreeSet<i32>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub spool: Option<Spool>,
81}
82
83#[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 High(u8),
91 Medium(u8),
93 Low(u8),
95 Rig(u8),
97 Subsystem(u8),
99 Service(u8),
101 FighterTube(u8),
103 FighterBay,
105 Implant(u8),
107 Booster(u16),
109 DroneBay,
111 Cargo,
113}
114
115#[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 Offline,
122 Online,
124 Active,
126 Overload,
128}
129
130#[cfg_attr(feature = "typescript", derive(Tsify))]
132#[derive(Serialize, Deserialize, Debug, Clone)]
133pub struct Charge {
134 pub type_id: i32,
136}
137
138#[cfg_attr(feature = "typescript", derive(Tsify))]
140#[derive(Serialize, Deserialize, Debug, Clone)]
141pub struct Mutation {
142 pub base: i32,
144 #[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#[cfg_attr(feature = "typescript", derive(Tsify))]
155#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
156#[serde(rename_all = "snake_case")]
157pub enum Spool {
158 MultiplierBonus(f64),
160}
161
162#[cfg_attr(feature = "typescript", derive(Tsify))]
164#[derive(Serialize, Deserialize, Debug, Clone, Default)]
165pub struct Character {
166 #[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 #[serde(default)]
176 pub security_status: f64,
177}
178
179#[cfg_attr(feature = "typescript", derive(Tsify))]
181#[derive(Serialize, Deserialize, Debug, Clone, Default)]
182pub struct Environment {
183 #[serde(default)]
185 pub damage_profile: DamageProfile,
186 #[serde(default)]
188 pub security: Security,
189 #[serde(default)]
191 pub reactive_armor: ReactiveArmor,
192}
193
194#[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 #[default]
204 DoNotAdapt,
205 DamageProfile,
207 Profile(DamageProfile),
209}
210
211#[cfg_attr(feature = "typescript", derive(Tsify))]
214#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq)]
215pub struct DamageProfile {
216 #[serde(default)]
218 pub em: f64,
219 #[serde(default)]
221 pub explosive: f64,
222 #[serde(default)]
224 pub kinetic: f64,
225 #[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#[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 #[default]
248 HighSec,
249 LowSec,
251 NullSec,
253 Wormhole,
255}
256
257fn one() -> u32 {
258 1
259}
260
261pub(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}