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