Skip to main content

sf_api/gamestate/
unlockables.rs

1use std::num::NonZeroU8;
2
3use chrono::{DateTime, Local};
4use enum_map::Enum;
5use log::error;
6use num_derive::FromPrimitive;
7use strum::EnumIter;
8
9use super::*;
10use crate::{PlayerId, gamestate::items::*, misc::*};
11
12/// Information about the Hellevator event on the server. If it is active, you
13/// can get more detailed info via `active()`
14#[derive(Debug, Default, Clone)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16pub struct HellevatorEvent {
17    /// The time the hellevator event was enabled at
18    pub start: Option<DateTime<Local>>,
19    /// The time the hellevator event will be disabled at
20    pub end: Option<DateTime<Local>>,
21    /// The time at which you will no longer be able to collect things for the
22    /// hellevator
23    pub collect_time_end: Option<DateTime<Local>>,
24    /// Contains the hellevator. This can be some(x), even if the event is not
25    /// going, so you should use the `active()` functions to get this
26    pub(crate) active: Option<Hellevator>,
27}
28
29#[derive(Debug)]
30pub enum HellevatorStatus<'a> {
31    /// The event is ongoing, but you have to send a `HellevatorEnter` command
32    /// to start using it
33    NotEntered,
34    /// The event is currently not available
35    NotAvailable,
36    /// The event has ended, but you can still claim the final reward
37    RewardClaimable,
38    /// A reference to the
39    Active(&'a Hellevator),
40}
41
42impl HellevatorEvent {
43    /// Checks if the event has started and not yet ended compared to the
44    /// current time
45    #[must_use]
46    pub fn is_event_ongoing(&self) -> bool {
47        let now = Local::now();
48        matches!((self.start, self.end), (Some(start), Some(end)) if end > now && start < now)
49    }
50
51    /// If the Hellevator event is active, this returns a reference to the
52    /// Information about it. Note that you still need to check the level >= 10
53    /// requirement yourself
54    #[must_use]
55    pub fn status(&self) -> HellevatorStatus<'_> {
56        match self.active.as_ref() {
57            None => HellevatorStatus::NotAvailable,
58            Some(h) if !self.is_event_ongoing() => {
59                if let Some(cend) = self.collect_time_end
60                    && !h.has_final_reward
61                    && Local::now() < cend
62                {
63                    return HellevatorStatus::RewardClaimable;
64                }
65                HellevatorStatus::NotAvailable
66            }
67            Some(h) if h.current_floor == 0 => HellevatorStatus::NotEntered,
68            Some(h) => HellevatorStatus::Active(h),
69        }
70    }
71
72    // /// If the Hellevator event is active, this returns a mutable reference
73    // to /// the Information about it
74    // #[must_use]
75    // pub fn active_mut(&mut self) -> Option<&mut Hellevator> {
76    //     let is_active = self.is_event_ongoing();
77    //     self.active.as_mut().filter(|_| is_active)
78    // }
79}
80
81#[derive(Debug, Default, Clone)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
83pub struct Hellevator {
84    pub key_cards: u32,
85    pub current_floor: u32,
86    pub points: u32,
87    pub has_final_reward: bool,
88
89    pub guild_points_today: u32,
90    pub guild_rank: u32,
91    pub guild_raid_floors: Vec<HellevatorRaidFloor>,
92
93    pub guild_raid_signup_start: DateTime<Local>,
94    pub guild_raid_start: DateTime<Local>,
95    pub monster_rewards: Vec<HellevatorMonsterReward>,
96
97    pub own_best_floor: u32,
98    pub shop_items: [HellevatorShopTreat; 3],
99
100    pub current_treat: Option<HellevatorShopTreat>,
101
102    pub next_card_generated: Option<DateTime<Local>>,
103    pub next_reset: Option<DateTime<Local>>,
104    pub start_contrib_date: Option<DateTime<Local>>,
105
106    pub rewards_yesterday: Option<HellevatorDailyReward>,
107    pub rewards_today: Option<HellevatorDailyReward>,
108    pub rewards_next: Option<HellevatorDailyReward>,
109
110    pub daily_treat_bonus: Option<HellevatorTreatBonus>,
111
112    pub current_monster: Option<HellevatorMonster>,
113
114    pub earned_today: u32,
115    pub earned_yesterday: u32,
116
117    pub(crate) brackets: Vec<u32>,
118}
119
120#[derive(Debug, Default, Clone)]
121#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
122pub struct HellevatorTreatBonus {
123    pub typ: HellevatorTreatBonusType,
124    pub amount: u32,
125}
126
127#[derive(Debug, Default, Clone)]
128#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
129pub struct HellevatorMonster {
130    pub id: i64,
131    pub level: u32,
132    pub typ: HellevatorMonsterElement,
133}
134
135#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash, FromPrimitive)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
137pub enum HellevatorMonsterElement {
138    Fire = 1,
139    Cold = 2,
140    Lightning = 3,
141    #[default]
142    Unknown = 240,
143}
144
145impl HellevatorMonster {
146    pub(crate) fn parse(data: &[i64]) -> Result<Self, SFError> {
147        Ok(HellevatorMonster {
148            id: data.cget(0, "h monster id")?,
149            level: data.csiget(1, "h monster level", 0)?,
150            typ: data.cfpget(2, "h monster typ", |a| a)?.unwrap_or_default(),
151        })
152    }
153}
154
155impl HellevatorTreatBonus {
156    pub(crate) fn parse(data: &[i64]) -> Result<Self, SFError> {
157        Ok(HellevatorTreatBonus {
158            typ: data
159                .cfpget(0, "hellevator treat bonus", |a| a)?
160                .unwrap_or_default(),
161            amount: data.csiget(1, "hellevator treat bonus a", 0)?,
162        })
163    }
164}
165
166#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash, FromPrimitive)]
167#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
168pub enum HellevatorTreatBonusType {
169    ExtraDamage = 14,
170    #[default]
171    Unknown = 240,
172}
173
174#[derive(Debug, Default, Clone)]
175#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
176pub struct HellevatorMonsterReward {
177    pub typ: HellevatorMonsterRewardTyp,
178    pub amount: u64,
179}
180
181#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
183pub enum HellevatorMonsterRewardTyp {
184    Points,
185    Tickets,
186    Mushrooms,
187    Silver,
188    LuckyCoin,
189    Wood,
190    Stone,
191    Arcane,
192    Metal,
193    Souls,
194    Fruit(HabitatType),
195
196    #[default]
197    Unknown,
198}
199
200impl HellevatorMonsterRewardTyp {
201    pub(crate) fn parse(data: i64) -> HellevatorMonsterRewardTyp {
202        match data {
203            1 => HellevatorMonsterRewardTyp::Points,
204            2 => HellevatorMonsterRewardTyp::Tickets,
205            3 => HellevatorMonsterRewardTyp::Mushrooms,
206            4 => HellevatorMonsterRewardTyp::Silver,
207            5 => HellevatorMonsterRewardTyp::LuckyCoin,
208            6 => HellevatorMonsterRewardTyp::Wood,
209            7 => HellevatorMonsterRewardTyp::Stone,
210            8 => HellevatorMonsterRewardTyp::Arcane,
211            9 => HellevatorMonsterRewardTyp::Metal,
212            10 => HellevatorMonsterRewardTyp::Souls,
213            11 => HellevatorMonsterRewardTyp::Fruit(HabitatType::Shadow),
214            12 => HellevatorMonsterRewardTyp::Fruit(HabitatType::Light),
215            13 => HellevatorMonsterRewardTyp::Fruit(HabitatType::Earth),
216            14 => HellevatorMonsterRewardTyp::Fruit(HabitatType::Fire),
217            15 => HellevatorMonsterRewardTyp::Fruit(HabitatType::Water),
218
219            _ => HellevatorMonsterRewardTyp::Unknown,
220        }
221    }
222}
223
224#[derive(Debug, Default, Clone)]
225#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
226pub struct HellevatorRaidFloor {
227    pub(crate) today: i64,
228    pub(crate) yesterday: i64,
229
230    pub point_reward: u32,
231    pub silver_reward: u64,
232
233    pub today_assigned: Vec<String>,
234    pub yesterday_assigned: Vec<String>,
235}
236
237#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Hash, FromPrimitive)]
238#[non_exhaustive]
239#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
240pub enum HellevatorTreatType {
241    ChocolateChilliPepper = 1,
242    PeppermintChocolate = 2,
243    Electroshock = 3,
244    ChillIceCream = 4,
245    CracklingChewingGum = 5,
246    PeppermintChewingGum = 6,
247    BeerBiscuit = 7,
248    GingerBreadHeart = 8,
249    FortuneCookie = 9,
250    CannedSpinach = 10,
251    StoneBiscuit = 11,
252    OrganicGranolaBar = 12,
253    ChocolateGoldCoin = 13,
254    #[default]
255    Unknown = 230,
256}
257
258#[derive(Debug, Default, Clone)]
259#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
260pub struct HellevatorShopTreat {
261    pub is_special: bool,
262    pub typ: HellevatorTreatType,
263    pub price: u32,
264    pub duration: u32,
265    pub effect_strength: u32,
266}
267
268#[derive(Debug, Clone, Default, PartialEq, Eq)]
269#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
270pub struct HellevatorDailyReward {
271    // TODO: What is the purpose of these fields?
272    pub(crate) start_level: u16,
273    pub(crate) end_level: u16,
274
275    pub gold_chests: u16,
276    pub silver: u64,
277
278    pub fortress_chests: u16,
279    pub wood: u64,
280    pub stone: u64,
281
282    pub blacksmith_chests: u16,
283    pub arcane: u64,
284    pub metal: u64,
285}
286
287impl HellevatorDailyReward {
288    /// Returns `true` if the daily reward can be claimed
289    #[must_use]
290    pub fn claimable(&self) -> bool {
291        self.gold_chests > 0
292            || self.fortress_chests > 0
293            || self.blacksmith_chests > 0
294    }
295
296    pub(crate) fn parse(data: &[i64]) -> Option<HellevatorDailyReward> {
297        if data.len() < 10 {
298            return None;
299        }
300
301        Some(HellevatorDailyReward {
302            start_level: data.csiget(0, "start level", 0).unwrap_or(0),
303            end_level: data.csiget(1, "end level", 0).unwrap_or(0),
304            gold_chests: data.csiget(2, "gold chests", 0).unwrap_or(0),
305            silver: data.csiget(5, "silver reward", 0).unwrap_or(0),
306            fortress_chests: data.csiget(3, "ft chests", 0).unwrap_or(0),
307            wood: data.csiget(6, "wood reward", 0).unwrap_or(0),
308            stone: data.csiget(7, "stone reward", 0).unwrap_or(0),
309            blacksmith_chests: data.csiget(4, "bs chests", 0).unwrap_or(0),
310            arcane: data.csiget(8, "arcane reward", 0).unwrap_or(0),
311            metal: data.csiget(9, "metal reward", 0).unwrap_or(0),
312        })
313    }
314}
315
316impl Hellevator {
317    /// Converts the rank of a guild in the Hellevator into the reward bracket,
318    /// that they would be in (1 to 25). If the rank would gain no rewards, none
319    /// is returned here
320    #[must_use]
321    pub fn rank_to_rewards_rank(&self, rank: u32) -> Option<u32> {
322        let mut rank_limit = 0;
323        let mut bracket = 0;
324        for bracket_len in &self.brackets {
325            bracket += 1;
326            rank_limit += *bracket_len;
327            if rank <= rank_limit {
328                return Some(bracket);
329            }
330        }
331        None
332    }
333
334    pub(crate) fn update(
335        &mut self,
336        data: &[i64],
337        server_time: ServerTime,
338    ) -> Result<(), SFError> {
339        self.key_cards = data.csiget(0, "h key cards", 0)?;
340        self.next_card_generated = data.cstget(1, "next card", server_time)?;
341        self.next_reset = data.cstget(2, "h next reset", server_time)?;
342        self.current_floor = data.csiget(3, "h current floor", 0)?;
343        self.points = data.csiget(4, "h points", 0)?;
344        self.start_contrib_date =
345            data.cstget(5, "start contrib", server_time)?;
346        self.has_final_reward = data.cget(6, "hellevator final")? == 1;
347        self.own_best_floor = data.csiget(7, "hellevator best rank", 0)?;
348
349        for (pos, shop_item) in self.shop_items.iter_mut().enumerate() {
350            let start = data.skip(8 + pos, "shop item start")?;
351            shop_item.typ = start
352                .cfpget(0, "hellevator shop treat", |a| a)?
353                .unwrap_or_default();
354            // FIXME: This is wrong
355            shop_item.is_special =
356                start.cget(3, "hellevator shop special")? > 0;
357            shop_item.price =
358                start.csiget(6, "hellevator shop price", u32::MAX)?;
359            shop_item.duration =
360                start.csiget(9, "hellevator shop duration", 0)?;
361            shop_item.effect_strength =
362                start.csiget(12, "hellevator effect str", 0)?;
363        }
364
365        let c_typ = data.cget(23, "current ctyp")?;
366        self.current_treat = if c_typ > 0 {
367            Some(HellevatorShopTreat {
368                typ: FromPrimitive::from_i64(c_typ).unwrap_or_default(),
369                is_special: data.cget(24, "current item special")? > 0,
370                price: 0,
371                duration: data.csiget(25, "current item remaining", 0)?,
372                effect_strength: data.csiget(26, "current item effect", 0)?,
373            })
374        } else {
375            None
376        };
377
378        self.earned_today = data.csiget(27, "points earned today", 0)?;
379        // 28 => probably a "has acknowledged rank fall" msg
380        self.earned_yesterday = data.csiget(29, "points earned yd", 0)?;
381        // 30 => fallen to rank
382        // 31 => ???
383        Ok(())
384    }
385}
386
387#[derive(Debug, Default, Clone)]
388#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
389pub struct Witch {
390    /// The item type the witch wants today
391    pub required_item: Option<EquipmentSlot>,
392    /// Whether or not the cauldron is bubbling
393    pub cauldron_bubbling: bool,
394    /// The enchant role collection progress from 0-100
395    pub progress: u32,
396    /// The price in silver to enchant an item
397    pub enchantment_price: u64,
398    /// Contains the ident to use when you want to apply the enchantment. If
399    /// this is `None`, the enchantment has not been unlocked yet
400    pub enchantments: EnumMap<Enchantment, Option<EnchantmentIdent>>,
401}
402
403/// The S&F server needs a character specific value for enchanting items. This
404/// is that value
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
407pub struct EnchantmentIdent(pub(crate) NonZeroU8);
408
409impl Witch {
410    pub(crate) fn update(&mut self, data: &[i64]) -> Result<(), SFError> {
411        self.enchantment_price = data.csiget(35, "witch price", u64::MAX)?;
412        self.required_item = None;
413        if data.cget(33, "w needs more")? == 0 {
414            let raw_required = data.cget(34, "w required")?;
415            for slot in EquipmentSlot::iter() {
416                let id = i64::from(slot.raw_id());
417                if id == raw_required {
418                    self.required_item = Some(slot);
419                    break;
420                }
421            }
422        }
423        if self.required_item.is_none() {
424            self.cauldron_bubbling = true;
425        } else {
426            // I would like to offer the raw values here, but the -1 just
427            // makes this annoying. A Option<(u32, u32)> is also weird
428            let current: i32 = data.ciget(2, "witch current")?;
429            let target: i32 = data.ciget(3, "witch target")?;
430            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
431            if current < 0 || target <= 0 {
432                self.progress = 100;
433            } else {
434                let current = f64::from(current);
435                let target = f64::from(target);
436                self.progress = ((current / target) * 100.0) as u32;
437            }
438        }
439
440        let e_count: u8 = data.ciget(4, "enchant count")?;
441        for i in 0..e_count {
442            let iid = data.cget(6 + 3 * i as usize, "iid")? - 1;
443            let key = match iid {
444                0 => continue,
445                10 => Enchantment::SwordOfVengeance,
446                30 => Enchantment::MariosBeard,
447                40 => Enchantment::ManyFeetBoots,
448                50 => Enchantment::ShadowOfTheCowboy,
449                60 => Enchantment::AdventurersArchaeologicalAura,
450                70 => Enchantment::ThirstyWanderer,
451                80 => Enchantment::UnholyAcquisitiveness,
452                90 => Enchantment::TheGraveRobbersPrayer,
453                100 => Enchantment::RobberBaronRitual,
454                x => {
455                    warn!("Unknown witch enchant itemtype: {x}");
456                    continue;
457                }
458            };
459            if let Some(val) = NonZeroU8::new(i + 1) {
460                *self.enchantments.get_mut(key) = Some(EnchantmentIdent(val));
461            }
462        }
463        Ok(())
464    }
465}
466
467#[derive(Debug, Clone, Default)]
468#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
469pub struct Blacksmith {
470    pub metal: u64,
471    pub arcane: u64,
472    pub dismantle_left: u8,
473    /// This seems to keep track of when you last dismantled. No idea why
474    pub last_dismantled: Option<DateTime<Local>>,
475}
476
477const PETS_PER_HABITAT: usize = 20;
478
479#[derive(Debug, Default, Clone)]
480#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
481pub struct Pets {
482    /// The total amount of pets collected in all habitats
483    pub total_collected: u16,
484    /// The rank this pet collection achieved in the hall of fame
485    pub rank: u32,
486    /// The honor this pet collection has gained
487    pub honor: u32,
488    pub max_pet_level: u16,
489    /// Information about the pvp opponent you can attack with your pets
490    pub opponent: PetOpponent,
491    /// Information about all the different habitats
492    pub habitats: EnumMap<HabitatType, Habitat>,
493    /// The next time the exploration will be possible without spending a
494    /// mushroom
495    pub next_free_exploration: Option<DateTime<Local>>,
496    /// The bonus the player receives from pets
497    pub atr_bonus: EnumMap<AttributeType, u32>,
498}
499
500/// Maps the index of the pet in their habitat to their base stats
501#[cfg(feature = "simulation")]
502static PET_BASE_STAT_ARRAY: [u32; 20] = [
503    10, 11, 12, 13, 14, 16, 18, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 130,
504    160, 160,
505];
506
507/// Maps the habitat relativ eindex of the pet to their class
508#[cfg(feature = "simulation")]
509#[rustfmt::skip]
510static PET_CLASS_LOOKUP: EnumMap<HabitatType, [Class; 20]> =
511    EnumMap::from_array([
512        // Shadow
513        [
514            Class::Scout,   Class::Warrior, Class::Warrior, Class::Mage,
515            Class::Mage,    Class::Mage,    Class::Scout,   Class::Scout,
516            Class::Scout,   Class::Warrior, Class::Mage,    Class::Mage,
517            Class::Scout,   Class::Scout,   Class::Warrior, Class::Warrior,
518            Class::Mage,    Class::Warrior, Class::Warrior, Class::Scout,
519        ],
520        // Light
521        [
522            Class::Warrior, Class::Warrior, Class::Mage,    Class::Mage,
523            Class::Scout,   Class::Scout,   Class::Mage,    Class::Warrior,
524            Class::Warrior, Class::Mage,    Class::Mage,    Class::Scout,
525            Class::Scout,   Class::Mage,    Class::Mage,    Class::Warrior,
526            Class::Warrior, Class::Warrior, Class::Mage,    Class::Scout,
527        ],
528        // Earth
529        [
530            Class::Warrior, Class::Warrior, Class::Scout,   Class::Scout,
531            Class::Warrior, Class::Scout,   Class::Mage,    Class::Mage,
532            Class::Warrior, Class::Warrior, Class::Scout,   Class::Warrior,
533            Class::Scout,   Class::Scout,   Class::Mage,    Class::Mage,
534            Class::Mage,    Class::Warrior, Class::Warrior, Class::Warrior,
535        ],
536        // Fire
537        [
538            Class::Scout,   Class::Scout,   Class::Warrior, Class::Mage,
539            Class::Mage,    Class::Scout,   Class::Scout,   Class::Mage,
540            Class::Warrior, Class::Mage,    Class::Mage,    Class::Scout,
541            Class::Scout,   Class::Scout,   Class::Scout,   Class::Scout,
542            Class::Mage,    Class::Warrior, Class::Mage,    Class::Warrior,
543        ],
544        // Water
545        [   Class::Mage,    Class::Warrior, Class::Warrior, Class::Warrior,
546            Class::Warrior, Class::Scout,   Class::Warrior, Class::Scout,
547            Class::Scout,   Class::Warrior, Class::Mage,    Class::Mage,
548            Class::Mage,    Class::Warrior, Class::Mage,    Class::Mage,
549            Class::Warrior, Class::Mage,    Class::Warrior, Class::Scout,
550        ],
551    ]);
552
553impl Pets {
554    /// Get the current monster we would be fighting, when
555    #[cfg(feature = "simulation")]
556    pub fn get_exploration_enemy(
557        &self,
558        habitat: HabitatType,
559    ) -> Option<crate::simulate::Monster> {
560        let h = &self.habitats[habitat];
561        let stage = match h.exploration {
562            HabitatExploration::Finished => return None,
563            HabitatExploration::Exploring { fights_won, .. } => fights_won,
564        };
565        crate::simulate::constants::PET_MONSTER
566            .get(&habitat)
567            .and_then(|a| a.get((stage) as usize))
568            .cloned()
569    }
570
571    /// Converts the given player pet into a fighter, usable in the simulation.
572    /// The given pet does not need to have stats populated to work, since all
573    /// stats will be dynamically calculated
574    #[cfg(feature = "simulation")]
575    #[must_use]
576    pub fn pet_to_fighter(
577        &self,
578        pet: &Pet,
579        gladiator: u32,
580    ) -> crate::simulate::Fighter {
581        let habitat_pets = &self.habitats[pet.element].pets;
582        let pack_bonus = habitat_pets
583            .iter()
584            .map(|a| match a.level {
585                0 => 0.0,
586                _ => 0.05,
587            })
588            .sum::<f64>();
589
590        let level_bonus = habitat_pets
591            .iter()
592            .map(|p| match p.level {
593                ..100 => 0.0,
594                100..150 => 0.05,
595                150..200 => 0.75,
596                200.. => 0.1,
597            })
598            .sum::<f64>();
599
600        let habitat_idx = habitat_pets
601            .iter()
602            .position(|a| a.id == pet.id)
603            .unwrap_or(0);
604
605        let base_stat =
606            PET_BASE_STAT_ARRAY.get(habitat_idx).copied().unwrap_or(0);
607        let high_stat = (f64::from(base_stat * (u32::from(pet.level) + 1))
608            * (1.0 + pack_bonus + level_bonus))
609            .floor();
610        let low_stat = (0.5 * high_stat).round();
611        let luck = (0.75 * high_stat).round();
612        let con = high_stat;
613
614        let class = *PET_CLASS_LOOKUP[pet.element]
615            .get(habitat_idx)
616            .unwrap_or(&Class::Warrior);
617
618        let (str, dex, int) = match class {
619            Class::Warrior => (high_stat, low_stat, low_stat),
620            Class::Mage => (low_stat, low_stat, high_stat),
621            _ => (low_stat, high_stat, low_stat),
622        };
623
624        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
625        let pet_fighter = crate::simulate::UpgradeableFighter {
626            name: format!(
627                "{:?} pet #{} ({}) ",
628                pet.element,
629                pet.id,
630                habitat_idx + 1
631            )
632            .into(),
633            class,
634            level: pet.level,
635            attribute_basis: EnumMap::from_array([
636                str as u32,
637                dex as u32,
638                int as u32,
639                con as u32,
640                luck as u32,
641            ]),
642            is_companion: false,
643            pet_attribute_bonus_perc: EnumMap::default(),
644            equipment: Equipment::default(),
645            active_potions: Default::default(),
646            portal_hp_bonus: 0,
647            portal_dmg_bonus: 0,
648            gladiator,
649        };
650        (&pet_fighter).into()
651    }
652}
653
654#[derive(Debug, Default, Clone)]
655#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
656pub struct Habitat {
657    /// The state of the exploration of this habitat
658    pub exploration: HabitatExploration,
659    /// The amount of fruits you have for this class
660    pub fruits: u16,
661    /// Has this habitat already fought an opponent today. If so, they can not
662    /// do this until the next day
663    pub battled_opponent: bool,
664    /// All the different pets you can collect in this habitat
665    pub pets: [Pet; PETS_PER_HABITAT],
666}
667
668/// Represents the current state of the habitat exploration
669#[derive(Debug, Default, Clone)]
670#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
671pub enum HabitatExploration {
672    #[default]
673    /// Explored/won all 20 habitat battles. This means you can no longer fight
674    /// in the habitat
675    Finished,
676    /// The habitat has not yet been fully explored
677    Exploring {
678        /// The amount of pets you have already won fights against (explored)
679        /// 0..=19
680        fights_won: u32,
681        /// The level of the next habitat exploration fight
682        next_fight_lvl: u16,
683    },
684}
685
686#[derive(Debug, Default, Clone)]
687#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
688pub struct PetOpponent {
689    pub id: PlayerId,
690    pub pet_count: u32,
691    pub level_total: u32,
692    /// The next time a battle against this opponent will cost no mushroom
693    pub next_free_battle: Option<DateTime<Local>>,
694    /// The time the opponent was chosen
695    pub reroll_date: Option<DateTime<Local>>,
696    pub habitat: Option<HabitatType>,
697}
698
699impl Pets {
700    pub(crate) fn update(
701        &mut self,
702        data: &[i64],
703        server_time: ServerTime,
704    ) -> Result<(), SFError> {
705        let mut pet_id = 0;
706        for (element_idx, element) in [
707            HabitatType::Shadow,
708            HabitatType::Light,
709            HabitatType::Earth,
710            HabitatType::Fire,
711            HabitatType::Water,
712        ]
713        .into_iter()
714        .enumerate()
715        {
716            let info = self.habitats.get_mut(element);
717            let explored = data.csiget(210 + element_idx, "pet exp", 20)?;
718            info.exploration = if explored == 20 {
719                HabitatExploration::Finished
720            } else {
721                let next_lvl =
722                    data.csiget(238 + element_idx, "next exp pet lvl", 1_000)?;
723                HabitatExploration::Exploring {
724                    fights_won: explored,
725                    next_fight_lvl: next_lvl,
726                }
727            };
728            for (pet_pos, pet) in info.pets.iter_mut().enumerate() {
729                pet_id += 1;
730                pet.id = pet_id;
731                pet.level =
732                    data.csiget((pet_id + 1) as usize, "pet level", 0)?;
733                pet.fruits_today =
734                    data.csiget((pet_id + 109) as usize, "pet fruits td", 0)?;
735                pet.element = element;
736                pet.can_be_found =
737                    pet.level == 0 && explored as usize >= pet_pos;
738            }
739            info.battled_opponent =
740                1 == data.cget(223 + element_idx, "element ff")?;
741        }
742
743        self.total_collected = data.csiget(103, "total pets", 0)?;
744        self.opponent.id = data.csiget(231, "pet opponent id", 0)?;
745        self.opponent.next_free_battle =
746            data.cstget(232, "next free pet fight", server_time)?;
747        self.rank = data.csiget(233, "pet rank", 0)?;
748        self.honor = data.csiget(234, "pet honor", 0)?;
749
750        self.opponent.pet_count = data.csiget(235, "pet enemy count", 0)?;
751        self.opponent.level_total =
752            data.csiget(236, "pet enemy lvl total", 0)?;
753        self.opponent.reroll_date =
754            data.cstget(237, "pet enemy reroll date", server_time)?;
755
756        update_enum_map(&mut self.atr_bonus, data.skip(250, "pet atr boni")?);
757        Ok(())
758    }
759
760    pub(crate) fn update_pet_stat(&mut self, data: &[i64]) {
761        match PetStats::parse(data) {
762            Ok(ps) => {
763                let idx = ps.id;
764                if let Some(pet) =
765                    self.habitats.get_mut(ps.element).pets.get_mut(idx % 20)
766                {
767                    pet.stats = Some(ps);
768                }
769            }
770            Err(e) => {
771                error!("Could not parse pet stats: {e}");
772            }
773        }
774    }
775}
776
777#[derive(Debug, Default, Clone)]
778#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
779pub struct Pet {
780    /// The unique id of this pet accross all habitats (1..=101)
781    pub id: u32,
782    pub level: u16,
783    /// The amount of fruits this pet got today
784    pub fruits_today: u16,
785    pub element: HabitatType,
786    /// This is None until you look at your pets again
787    pub stats: Option<PetStats>,
788    /// Check if this pet can be found already
789    pub can_be_found: bool,
790}
791
792#[derive(Debug, Default, Clone)]
793#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
794pub struct PetStats {
795    pub id: usize,
796    pub level: u16,
797    pub armor: u16,
798    pub class: Class,
799    pub attributes: EnumMap<AttributeType, u32>,
800    pub bonus_attributes: EnumMap<AttributeType, u32>,
801    pub min_damage: u16,
802    pub max_damage: u16,
803    pub element: HabitatType,
804}
805
806#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Enum, EnumIter, Hash)]
807#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
808pub enum HabitatType {
809    #[default]
810    Shadow = 0,
811    Light = 1,
812    Earth = 2,
813    Fire = 3,
814    Water = 4,
815}
816
817impl From<HabitatType> for AttributeType {
818    fn from(value: HabitatType) -> Self {
819        match value {
820            HabitatType::Water => AttributeType::Strength,
821            HabitatType::Light => AttributeType::Dexterity,
822            HabitatType::Earth => AttributeType::Intelligence,
823            HabitatType::Shadow => AttributeType::Constitution,
824            HabitatType::Fire => AttributeType::Luck,
825        }
826    }
827}
828
829impl HabitatType {
830    pub(crate) fn from_pet_id(id: i64) -> Option<Self> {
831        Some(match id {
832            1..=20 => HabitatType::Shadow,
833            21..=40 => HabitatType::Light,
834            41..=60 => HabitatType::Earth,
835            61..=80 => HabitatType::Fire,
836            81..=100 => HabitatType::Water,
837            _ => return None,
838        })
839    }
840
841    pub(crate) fn from_typ_id(id: i64) -> Option<Self> {
842        Some(match id {
843            1 => HabitatType::Shadow,
844            2 => HabitatType::Light,
845            3 => HabitatType::Earth,
846            4 => HabitatType::Fire,
847            5 => HabitatType::Water,
848            _ => return None,
849        })
850    }
851}
852
853impl PetStats {
854    pub(crate) fn parse(data: &[i64]) -> Result<Self, SFError> {
855        let pet_id: u32 = data.csiget(0, "pet index", 0)?;
856        let mut s = Self {
857            id: pet_id as usize,
858            level: data.csiget(1, "pet lvl", 0)?,
859            armor: data.csiget(2, "pet armor", 0)?,
860            class: data.cfpuget(3, "pet class", |a| a)?,
861            min_damage: data.csiget(14, "min damage", 0)?,
862            max_damage: data.csiget(15, "max damage", 0)?,
863
864            element: match data.cget(16, "pet element")? {
865                0 => HabitatType::from_pet_id(i64::from(pet_id)).ok_or_else(
866                    || SFError::ParsingError("det pet typ", pet_id.to_string()),
867                )?,
868                x => HabitatType::from_typ_id(x).ok_or_else(|| {
869                    SFError::ParsingError("det pet typ", x.to_string())
870                })?,
871            },
872            ..Default::default()
873        };
874        update_enum_map(&mut s.attributes, data.skip(4, "pet attrs")?);
875        update_enum_map(&mut s.bonus_attributes, data.skip(9, "pet bonus")?);
876        Ok(s)
877    }
878}
879
880#[derive(Debug, Clone, Copy, PartialEq, Eq)]
881#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
882pub struct Unlockable {
883    /// Something like `Dungeon-key`
884    pub main_ident: i64,
885    /// Would be a specification of the main ident like for which dungeon
886    pub sub_ident: i64,
887}
888
889impl Unlockable {
890    pub(crate) fn parse(data: &[i64]) -> Result<Vec<Unlockable>, SFError> {
891        data.chunks_exact(2)
892            .filter(|chunk| chunk.first().copied().unwrap_or_default() != 0)
893            .map(|chunk| {
894                Ok(Unlockable {
895                    main_ident: chunk.cget(0, "unlockable ident")?,
896                    sub_ident: chunk.cget(1, "unlockable sub ident")?,
897                })
898            })
899            .collect()
900    }
901}
902
903/// The current progress towards all achievements
904#[derive(Debug, Default, Clone)]
905#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
906pub struct Achievements(pub Vec<Achievement>);
907
908impl Achievements {
909    pub(crate) fn update(&mut self, data: &[i64]) -> Result<(), SFError> {
910        self.0.clear();
911        let total_count = data.len() / 2;
912        if !data.len().is_multiple_of(2) {
913            warn!("achievement data has the wrong length: {}", data.len());
914            return Ok(());
915        }
916
917        for i in 0..total_count {
918            self.0.push(Achievement {
919                achieved: data.cget(i, "achievement achieved")? == 1,
920                progress: data.cget(i + total_count, "achievement achieved")?,
921            });
922        }
923        Ok(())
924    }
925
926    /// The amount of achievements, that have been earned
927    #[must_use]
928    pub fn owned(&self) -> u32 {
929        self.0.iter().map(|a| u32::from(a.achieved)).sum()
930    }
931}
932
933/// A small challenge you can complete in the game
934#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
935#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
936pub struct Achievement {
937    /// Whether or not this achievement has been completed
938    pub achieved: bool,
939    /// The progress of doing this achievement
940    pub progress: i64,
941}
942
943/// Contains all the items & monsters you have found in the scrapbook
944#[derive(Debug, Clone)]
945#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
946pub struct ScrapBook {
947    /// All the items, that this player has already collected. To check if an
948    /// item is in this, you should call `equipment_ident()` on an item and see
949    /// if this item contains that
950    pub items: HashSet<EquipmentIdent>,
951    /// All the monsters, that the player has seen already. I have only checked
952    /// this once, but this should match the tavern monster id.
953    // TODO: Dungeon monster ids?
954    pub monster: HashSet<u16>,
955}
956
957impl ScrapBook {
958    // 99% based on Hubert LipiƄskis Code
959    // https://github.com/HubertLipinski/sfgame-scrapbook-helper
960    pub(crate) fn parse(val: &str) -> Option<ScrapBook> {
961        let text = base64::Engine::decode(
962            &base64::engine::general_purpose::URL_SAFE,
963            val,
964        )
965        .ok()?;
966        if text.iter().all(|a| *a == 0) {
967            return None;
968        }
969
970        let mut index = 0;
971        let mut items = HashSet::new();
972        let mut monster = HashSet::new();
973
974        for byte in text {
975            for bit_pos in (0..=7).rev() {
976                index += 1;
977                let is_owned = ((byte >> bit_pos) & 1) == 1;
978                if !is_owned {
979                    continue;
980                }
981                if index < 801 {
982                    // Monster
983                    monster.insert(index.try_into().unwrap_or_default());
984                } else if let Some(ident) = parse_scrapbook_item(index) {
985                    // Items
986                    if !items.insert(ident) {
987                        error!(
988                            "Two scrapbook positions parsed to the same \
989                             ident: {index}"
990                        );
991                    }
992                } else {
993                    error!("Owned, but not parsed: {index}");
994                }
995            }
996        }
997        Some(ScrapBook { items, monster })
998    }
999}
1000
1001/// The identification of items in the scrapbook
1002#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1003#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1004pub struct EquipmentIdent {
1005    /// The class the item has and thus the wearer must have
1006    pub class: Option<Class>,
1007    /// The position at which the item is worn
1008    pub typ: EquipmentSlot,
1009    /// The model id, this is basically the "name"" of the item
1010    pub model_id: u16,
1011    /// The color variation of this item
1012    pub color: u8,
1013}
1014
1015#[allow(clippy::to_string_trait_impl)]
1016impl ToString for EquipmentIdent {
1017    fn to_string(&self) -> String {
1018        let item_typ = self.typ.raw_id();
1019        let model_id = self.model_id;
1020        let color = self.color;
1021
1022        if let Some(class) = self.class {
1023            let ci = class as u8 + 1;
1024            format!("itm{item_typ}_{model_id}_{color}_{ci}")
1025        } else {
1026            format!("itm{item_typ}_{model_id}_{color}")
1027        }
1028    }
1029}
1030
1031#[allow(clippy::enum_glob_use)]
1032fn parse_scrapbook_item(item_idx: i64) -> Option<EquipmentIdent> {
1033    use Class::*;
1034    use EquipmentSlot::*;
1035    let slots: [(_, _, _, &[_]); 44] = [
1036        (801..1011, Amulet, None, &[]),
1037        (1011..1051, Amulet, None, &[]),
1038        (1051..1211, Ring, None, &[]),
1039        (1211..1251, Ring, None, &[]),
1040        (1251..1325, Talisman, None, &[]),
1041        (1325..1365, Talisman, None, &[]),
1042        (1365..1665, Weapon, Some(Warrior), &[]),
1043        (1665..1705, Weapon, Some(Warrior), &[]),
1044        (1705..1805, Shield, Some(Warrior), &[]),
1045        (1805..1845, Shield, Some(Warrior), &[]),
1046        (1845..1945, BreastPlate, Some(Warrior), &[]),
1047        (1945..1985, BreastPlate, Some(Warrior), &[1954, 1955]),
1048        (1985..2085, FootWear, Some(Warrior), &[]),
1049        (2085..2125, FootWear, Some(Warrior), &[2094, 2095]),
1050        (2125..2225, Gloves, Some(Warrior), &[]),
1051        (2225..2265, Gloves, Some(Warrior), &[2234, 2235]),
1052        (2265..2365, Hat, Some(Warrior), &[]),
1053        (2365..2405, Hat, Some(Warrior), &[2374, 2375]),
1054        (2405..2505, Belt, Some(Warrior), &[]),
1055        (2505..2545, Belt, Some(Warrior), &[2514, 2515]),
1056        (2545..2645, Weapon, Some(Mage), &[]),
1057        (2645..2685, Weapon, Some(Mage), &[]),
1058        (2685..2785, BreastPlate, Some(Mage), &[]),
1059        (2785..2825, BreastPlate, Some(Mage), &[2794, 2795]),
1060        (2825..2925, FootWear, Some(Mage), &[]),
1061        (2925..2965, FootWear, Some(Mage), &[2934, 2935]),
1062        (2965..3065, Gloves, Some(Mage), &[]),
1063        (3065..3105, Gloves, Some(Mage), &[3074, 3075]),
1064        (3105..3205, Hat, Some(Mage), &[]),
1065        (3205..3245, Hat, Some(Mage), &[3214, 3215]),
1066        (3245..3345, Belt, Some(Mage), &[]),
1067        (3345..3385, Belt, Some(Mage), &[3354, 3355]),
1068        (3385..3485, Weapon, Some(Scout), &[]),
1069        (3485..3525, Weapon, Some(Scout), &[]),
1070        (3525..3625, BreastPlate, Some(Scout), &[]),
1071        (3625..3665, BreastPlate, Some(Scout), &[3634, 3635]),
1072        (3665..3765, FootWear, Some(Scout), &[]),
1073        (3765..3805, FootWear, Some(Scout), &[3774, 3775]),
1074        (3805..3905, Gloves, Some(Scout), &[]),
1075        (3905..3945, Gloves, Some(Scout), &[3914, 3915]),
1076        (3945..4045, Hat, Some(Scout), &[]),
1077        (4045..4085, Hat, Some(Scout), &[4054, 4055]),
1078        (4085..4185, Belt, Some(Scout), &[]),
1079        (4185..4225, Belt, Some(Scout), &[4194, 4195]),
1080    ];
1081
1082    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1083    for (pos, (range, typ, class, ignore)) in slots.into_iter().enumerate() {
1084        if !range.contains(&item_idx) {
1085            continue;
1086        }
1087        if ignore.contains(&item_idx) {
1088            return None;
1089        }
1090
1091        let is_epic = pos % 2 == 1;
1092        let relative_pos = item_idx - range.start + 1;
1093
1094        let color = match relative_pos % 10 {
1095            _ if typ == Talisman || is_epic => 1,
1096            0 => 5,
1097            1..=5 => relative_pos % 10,
1098            _ => relative_pos % 10 - 5,
1099        } as u8;
1100
1101        let model_id = match () {
1102            () if is_epic => relative_pos + 49,
1103            () if typ == Talisman => relative_pos,
1104            () if relative_pos % 5 != 0 => relative_pos / 5 + 1,
1105            () => relative_pos / 5,
1106        } as u16;
1107
1108        return Some(EquipmentIdent {
1109            class,
1110            typ,
1111            model_id,
1112            color,
1113        });
1114    }
1115    None
1116}