sf_api/gamestate/
guild.rs

1#![allow(clippy::module_name_repetitions)]
2use chrono::{DateTime, Local, NaiveTime};
3use enum_map::EnumMap;
4use log::warn;
5use num_derive::FromPrimitive;
6
7use super::{
8    ArrSkip, AttributeType, CCGet, CFPGet, CGet, CSTGet, NormalCost, Potion,
9    SFError, ServerTime,
10    items::{ItemType, PotionSize, PotionType},
11    update_enum_map,
12};
13use crate::misc::{from_sf_string, soft_into, warning_parse};
14
15/// Information about the characters current guild
16#[derive(Debug, Clone, Default)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct Guild {
19    /// The internal server id of this guild
20    pub id: u32,
21    /// The name of the guild
22    pub name: String,
23    /// The description text of the guild
24    pub description: String,
25    /// This is guilds emblem. Currently this is unparsed, so you only have
26    /// access to the raw string
27    pub emblem: Emblem,
28
29    /// The honor this guild has earned
30    pub honor: u32,
31    /// The rank in the Hall of Fame this guild has
32    pub rank: u32,
33    /// The date at which the character joined this guild
34    pub joined: DateTime<Local>,
35
36    /// The skill you yourself contribute to the guild
37    pub own_treasure_skill: u16,
38    /// The price to pay to upgrade your treasure by one rank
39    pub own_treasure_upgrade: NormalCost,
40    /// The total amount of treasure skill the guild has
41    pub total_treasure_skill: u16,
42
43    /// The skill you yourself contribute to the guild
44    pub own_instructor_skill: u16,
45    /// The price to pay to upgrade your instructor by one rank
46    pub own_instructor_upgrade: NormalCost,
47    /// The total amount of instructor skill the guild has
48    pub total_instructor_skill: u16,
49
50    /// How many raids this guild has completed already
51    pub finished_raids: u16,
52
53    /// If the guild is defending against another guild, this will contain
54    /// information about the upcoming battle
55    pub defending: Option<PlanedBattle>,
56    /// If the guild is attacking another guild, this will contain
57    /// information about the upcoming battle
58    pub attacking: Option<PlanedBattle>,
59    /// The next time the guild can attack another guild.
60    pub next_attack_possible: Option<DateTime<Local>>,
61
62    /// The id of the pet, that is currently selected as the guild pet
63    pub pet_id: u32,
64    /// The maximum level, that the pet can be at
65    pub pet_max_lvl: u16,
66    /// All information about the hydra the guild pet can fight
67    pub hydra: GuildHydra,
68    /// The thing each player can enter and fight once a day
69    pub portal: GuildPortal,
70
71    // This should just be members.len(). I think this is only in the API
72    // because they are bad at varsize arrays or smth.
73    member_count: u8,
74    /// Information about the members of the guild. This includes the player
75    pub members: Vec<GuildMemberData>,
76    /// The chat messages, that get send in the guild chat
77    pub chat: Vec<ChatMessage>,
78    /// The whisper messages, that a player can receive
79    pub whispers: Vec<ChatMessage>,
80
81    /// A list of guilds which can be fought, must first be fetched by sending
82    /// `Command::GuildGetFightableTargets`
83    pub fightable_guilds: Vec<FightableGuild>,
84}
85
86/// The hydra, that the guild pet can fight
87#[derive(Debug, Clone, Default)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
89pub struct GuildHydra {
90    /// The last time the hydra has been fought
91    pub last_battle: Option<DateTime<Local>>,
92    /// The last time the hydra has been seen with full health
93    pub last_full: Option<DateTime<Local>>,
94    /// This seems to be `last_battle + 30 min`. I can only do 1 battle/day,
95    /// but I think this should be the next possible fight
96    pub next_battle: Option<DateTime<Local>>,
97    /// The amount of times the player can still fight the hydra
98    pub remaining_fights: u16,
99    /// The current life of the guilds hydra
100    pub current_life: u64,
101    /// The maximum life the hydra can have
102    pub max_life: u64,
103    /// The attributes the hydra has
104    pub attributes: EnumMap<AttributeType, u32>,
105}
106
107/// Contains information about another guild which can be fought.
108/// Must first be fetched by sending `Command::GuildGetFightableTargets`
109#[derive(Debug, Clone, PartialEq, Default)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
111pub struct FightableGuild {
112    /// Id of the guild
113    pub id: u32,
114    /// Name of the guild
115    pub name: String,
116    /// Emblem of the guild
117    pub emblem: Emblem,
118    /// Number of members the guild currently has
119    pub number_of_members: u8,
120    /// The lowest level a member of the guild has
121    pub members_min_level: u32,
122    /// The highest level a member of the guild has
123    pub members_max_level: u32,
124    /// The average level of the guild members
125    pub members_average_level: u32,
126    /// The rank of the guild in the hall of fame
127    pub rank: u32,
128    /// The amount of honor the guild currently has
129    pub honor: u32,
130}
131
132/// The customizable emblem each guild has
133#[derive(Debug, Clone, Default, PartialEq)]
134#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
135pub struct Emblem {
136    raw: String,
137}
138
139impl Emblem {
140    /// Returns the guild emblem in it's server encoded form
141    #[must_use]
142    pub fn server_encode(&self) -> String {
143        // TODO: Actually parse this
144        self.raw.clone()
145    }
146
147    pub(crate) fn update(&mut self, str: &str) {
148        self.raw.clear();
149        self.raw.push_str(str);
150    }
151}
152
153/// A message, that the player has received, or has send to others via the chat
154#[derive(Debug, Clone, Default)]
155#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
156pub struct ChatMessage {
157    /// The user this message originated from. Note that this might not be in
158    /// the guild member list in some cases
159    pub user: String,
160    /// The time at which this message has been sent. I have not checked the
161    /// timezone here. Might be UTC/Your TZ/Server TZ
162    pub time: NaiveTime,
163    /// The actual bessage, that got send
164    pub message: String,
165}
166
167impl ChatMessage {
168    pub(crate) fn parse_messages(data: &str) -> Vec<ChatMessage> {
169        data.split('/')
170            .filter_map(|msg| {
171                let (time, rest) = msg.split_once(' ')?;
172                let (name, msg) = rest.split_once(':')?;
173                let msg = from_sf_string(msg.trim_start_matches(['§', ' ']));
174                let time = NaiveTime::parse_from_str(time, "%H:%M").ok()?;
175                Some(ChatMessage {
176                    user: name.to_string(),
177                    time,
178                    message: msg,
179                })
180            })
181            .collect()
182    }
183}
184
185impl Guild {
186    pub(crate) fn update_group_save(
187        &mut self,
188        val: &str,
189        server_time: ServerTime,
190    ) -> Result<(), SFError> {
191        let data: Vec<_> = val
192            .split('/')
193            .map(|c| c.trim().parse::<i64>().unwrap_or_default())
194            .collect();
195
196        let member_count = data.csiget(3, "member count", 0)?;
197        self.member_count = member_count;
198        self.members
199            .resize_with(member_count as usize, Default::default);
200
201        for (offset, member) in self.members.iter_mut().enumerate() {
202            member.battles_joined =
203                data.cfpget(445 + offset, "member fights joined", |x| x % 100)?;
204            member.level = data.csiget(64 + offset, "member level", 0)?;
205            member.last_online =
206                data.cstget(114 + offset, "member last online", server_time)?;
207            member.treasure_skill =
208                data.csiget(214 + offset, "member treasure skill", 0)?;
209            member.instructor_skill =
210                data.csiget(264 + offset, "member master skill", 0)?;
211            member.guild_rank = match data.cget(314 + offset, "member rank")? {
212                1 => GuildRank::Leader,
213                2 => GuildRank::Officer,
214                3 => GuildRank::Member,
215                4 => GuildRank::Invited,
216                x => {
217                    warn!("Unknown guild rank: {x}");
218                    GuildRank::Invited
219                }
220            };
221            member.portal_fought =
222                data.cstget(164 + offset, "member portal fought", server_time)?;
223            member.guild_pet_lvl =
224                data.csiget(390 + offset, "member pet skill", 0)?;
225        }
226
227        self.honor = data.csiget(13, "guild honor", 0)?;
228        self.id = data.csiget(0, "guild id", 0)?;
229
230        self.finished_raids = data.csiget(8, "finished raids", 0)?;
231
232        self.attacking = PlanedBattle::parse(
233            data.skip(364, "attacking guild")?,
234            server_time,
235        )?;
236
237        self.defending = PlanedBattle::parse(
238            data.skip(366, "attacking guild")?,
239            server_time,
240        )?;
241
242        self.next_attack_possible =
243            data.cstget(365, "guild next attack time", server_time)?;
244
245        self.pet_id = data.csiget(377, "gpet id", 0)?;
246        self.pet_max_lvl = data.csiget(378, "gpet max lvl", 0)?;
247
248        self.hydra.last_battle =
249            data.cstget(382, "hydra pet lb", server_time)?;
250        self.hydra.last_full =
251            data.cstget(381, "hydra last defeat", server_time)?;
252
253        self.hydra.current_life = data.csiget(383, "ghydra clife", u64::MAX)?;
254        self.hydra.max_life = data.csiget(384, "ghydra max clife", u64::MAX)?;
255
256        update_enum_map(
257            &mut self.hydra.attributes,
258            data.skip(385, "hydra attributes")?,
259        );
260
261        self.total_treasure_skill =
262            data.csimget(6, "guild total treasure skill", 0, |x| x & 0xFFFF)?;
263        self.total_instructor_skill =
264            data.csimget(7, "guild total instructor skill", 0, |x| x & 0xFFFF)?;
265
266        self.portal.life_percentage =
267            data.csimget(6, "guild portal life p", 100, |x| x >> 16)?;
268        self.portal.defeated_count =
269            data.csimget(7, "guild portal progress", 0, |x| x >> 16)?;
270
271        Ok(())
272    }
273
274    pub(crate) fn update_member_names(&mut self, val: &str) {
275        let names: Vec<_> = val
276            .split(',')
277            .map(std::string::ToString::to_string)
278            .collect();
279        self.members.resize_with(names.len(), Default::default);
280        for (member, name) in self.members.iter_mut().zip(names) {
281            member.name = name;
282        }
283    }
284
285    pub(crate) fn update_group_knights(&mut self, val: &str) {
286        let data: Vec<i64> = val
287            .trim_end_matches(',')
288            .split(',')
289            .flat_map(str::parse)
290            .collect();
291
292        self.members.resize_with(data.len(), Default::default);
293        for (member, count) in self.members.iter_mut().zip(data) {
294            member.knights = soft_into(count, "guild knight", 0);
295        }
296    }
297
298    pub(crate) fn update_member_potions(&mut self, val: &str) {
299        let data = val
300            .trim_end_matches(',')
301            .split(',')
302            .map(|c| {
303                warning_parse(c, "member potion", |a| a.parse::<i64>().ok())
304                    .unwrap_or_default()
305            })
306            .collect::<Vec<_>>();
307
308        let potions = data.len() / 2;
309        let member = potions / 3;
310        self.members.resize_with(member, Default::default);
311
312        let mut data = data.into_iter();
313
314        let quick_potion = |int: i64| {
315            Some(ItemType::Potion(Potion {
316                typ: PotionType::parse(int)?,
317                size: PotionSize::parse(int)?,
318                expires: None,
319            }))
320        };
321
322        for member in &mut self.members {
323            for potion in &mut member.potions {
324                *potion = data
325                    .next()
326                    .or_else(|| {
327                        warn!("Invalid member potion len");
328                        None
329                    })
330                    .and_then(quick_potion);
331                _ = data.next();
332            }
333        }
334    }
335
336    pub(crate) fn update_description_embed(&mut self, data: &str) {
337        let Some((emblem, description)) = data.split_once('§') else {
338            self.description = from_sf_string(data);
339            return;
340        };
341
342        self.description = from_sf_string(description);
343        self.emblem.update(emblem);
344    }
345
346    pub(crate) fn update_group_prices(
347        &mut self,
348        data: &[i64],
349    ) -> Result<(), SFError> {
350        self.own_treasure_upgrade.silver =
351            data.csiget(0, "treasure upgr. silver", 0)?;
352        self.own_treasure_upgrade.mushrooms =
353            data.csiget(1, "treasure upgr. mush", 0)?;
354        self.own_instructor_upgrade.silver =
355            data.csiget(2, "instr upgr. silver", 0)?;
356        self.own_instructor_upgrade.mushrooms =
357            data.csiget(3, "instr upgr. mush", 0)?;
358        Ok(())
359    }
360
361    #[allow(clippy::indexing_slicing)]
362    pub(crate) fn update_fightable_targets(
363        &mut self,
364        data: &str,
365    ) -> Result<(), SFError> {
366        const SIZE: usize = 9;
367
368        // Delete any old data
369        self.fightable_guilds.clear();
370
371        let entries = data.trim_end_matches('/').split('/').collect::<Vec<_>>();
372
373        let target_counts = entries.len() / SIZE;
374
375        // Check if the data is valid
376        if target_counts * SIZE != entries.len() {
377            warn!("Invalid fightable targets len");
378            return Err(SFError::ParsingError(
379                "Fightable targets invalid length",
380                data.to_string(),
381            ));
382        }
383
384        // Reserve space for the new data
385        self.fightable_guilds.reserve(entries.len() / SIZE);
386
387        for i in 0..entries.len() / SIZE {
388            let offset = i * SIZE;
389
390            self.fightable_guilds.push(FightableGuild {
391                id: entries[offset].parse().unwrap_or_default(),
392                name: from_sf_string(entries[offset + 1]),
393                emblem: Emblem {
394                    raw: entries[offset + 2].to_string(),
395                },
396                number_of_members: entries[offset + 3]
397                    .parse()
398                    .unwrap_or_default(),
399                members_min_level: entries[offset + 4]
400                    .parse()
401                    .unwrap_or_default(),
402                members_max_level: entries[offset + 5]
403                    .parse()
404                    .unwrap_or_default(),
405                members_average_level: entries[offset + 6]
406                    .parse()
407                    .unwrap_or_default(),
408                rank: entries[offset + 7].parse().unwrap_or_default(),
409                honor: entries[offset + 8].parse().unwrap_or_default(),
410            });
411        }
412
413        Ok(())
414    }
415}
416
417/// A guild battle, that is scheduled to take place at a certain place and time
418#[derive(Debug, Default, Clone)]
419#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
420pub struct PlanedBattle {
421    /// The guild this battle will be against
422    pub other: u32,
423    /// The date & time this battle will be at
424    pub date: DateTime<Local>,
425}
426
427impl PlanedBattle {
428    /// Checks if the battle is a raid
429    #[must_use]
430    pub fn is_raid(&self) -> bool {
431        self.other == 1_000_000
432    }
433
434    #[allow(clippy::similar_names)]
435    fn parse(
436        data: &[i64],
437        server_time: ServerTime,
438    ) -> Result<Option<Self>, SFError> {
439        let other = data.cget(0, "gbattle other")?;
440        let other = match other.try_into() {
441            Ok(x) if x > 1 => Some(x),
442            _ => None,
443        };
444        let date = data.cget(1, "gbattle time")?;
445        let date = server_time.convert_to_local(date, "next guild fight");
446        Ok(match (other, date) {
447            (Some(other), Some(date)) => Some(Self { other, date }),
448            _ => None,
449        })
450    }
451}
452
453/// The portal a guild has
454#[derive(Debug, Default, Clone)]
455#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
456pub struct GuildPortal {
457    /// The damage bonus in percent the guild portal gives to its members
458    pub damage_bonus: u8,
459    /// The amount of times the portal enemy has already been defeated. You can
460    /// easily convert this int oct & stage if you want
461    pub defeated_count: u8,
462    /// The percentage of life the portal enemy still has
463    pub life_percentage: u8,
464}
465
466/// Which battles a member will participate in
467#[derive(Debug, Copy, Clone, FromPrimitive)]
468#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
469pub enum BattlesJoined {
470    /// The player has only joined the defense of the guild
471    Defense = 1,
472    /// The player has only joined the offensive attack against another guild
473    Attack = 10,
474    /// The player has only joined both the offense and defensive battles of
475    /// the guild
476    Both = 11,
477}
478
479/// A member of a guild
480#[derive(Debug, Clone, Default)]
481#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
482pub struct GuildMemberData {
483    /// The name of the member
484    pub name: String,
485    /// Which battles this member will participate in
486    pub battles_joined: Option<BattlesJoined>,
487    /// The level of this member
488    pub level: u16,
489    /// The last time this player was online (last time they send an update
490    /// command)
491    pub last_online: Option<DateTime<Local>>,
492    /// The level, that this member has upgraded their treasure to
493    pub treasure_skill: u16,
494    /// The level, that this member has upgraded their instructor to
495    pub instructor_skill: u16,
496    /// The level of this members guild pet
497    pub guild_pet_lvl: u16,
498
499    /// The rank this member has in the guild
500    pub guild_rank: GuildRank,
501    /// The last time this member has fought the portal. This is basically a
502    /// dynamic check if they have fought it today, because today changes
503    pub portal_fought: Option<DateTime<Local>>,
504    /// The potions this player has active. This will always be potion, no
505    /// other item type
506    // TODO: make this explicit
507    pub potions: [Option<ItemType>; 3],
508    /// The level of this members hall of knights
509    pub knights: u8,
510}
511
512/// The rank a member can have in a guild
513#[derive(Debug, Clone, Copy, FromPrimitive, Default)]
514#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
515#[allow(missing_docs)]
516pub enum GuildRank {
517    Leader = 1,
518    Officer = 2,
519    #[default]
520    Member = 3,
521    Invited = 4,
522}
523
524/// Something the player can upgrade in the guild
525#[derive(Debug, Clone, Copy, PartialEq)]
526#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
527#[allow(missing_docs)]
528pub enum GuildSkill {
529    Treasure = 0,
530    Instructor,
531    Pet,
532}