overlord_event_system/
state.rs

1use std::collections::HashMap;
2
3use configs::game_config::GameConfig;
4use essences::abilities::{Ability, AbilityTemplate};
5use essences::arena::Arena;
6use essences::autochest::AutoChest;
7use essences::bundles::{BundleElement, BundleId};
8use essences::character_state::CharacterState;
9use essences::dungeons::Dungeons;
10use essences::entity::Entity;
11use essences::fight_breakdown::FightBreakdown;
12use essences::fighting::ActiveFight;
13use essences::flip::FlipState;
14use essences::gift::Gift;
15use essences::items::Item;
16use essences::mail::Mail;
17use essences::offers::OffersInfo;
18use essences::prelude::*;
19use essences::progress_pass::ProgressPassState;
20use essences::pvp::PVPState;
21use essences::quest::QuestsGroups;
22use essences::ratings::RatingRewardAvailability;
23use essences::referrals::Patron;
24
25use event_system::state::State;
26
27use crate::BehaviorRegistry;
28use crate::bundles::{bundle_raw_afk_step_to_element, bundle_raw_step_to_element};
29use crate::game_config_helpers::GameConfigLookup;
30use crate::party::Party;
31use schemars::JsonSchema;
32use strum::{EnumIter, IntoEnumIterator};
33
34/// Status value stored in the `user_accesses` table (e.g. admin). Used when querying DB.
35#[derive(
36    Clone, Copy, PartialEq, Eq, Debug, Tsify, Serialize, Deserialize, JsonSchema, EnumIter,
37)]
38#[serde(rename_all = "lowercase")]
39pub enum UserStatus {
40    Admin,
41    Banned,
42}
43
44impl UserStatus {
45    /// String stored in the database.
46    pub fn as_str(self) -> &'static str {
47        match self {
48            UserStatus::Admin => "admin",
49            UserStatus::Banned => "banned",
50        }
51    }
52}
53
54/// Permission flags for the current user (e.g. cheats, logout).
55/// Populated from the `user_accesses` table when state is loaded; e.g. status `admin` grants all.
56#[derive(Clone, PartialEq, Eq, Debug, Tsify, Serialize, Deserialize, JsonSchema, EnumIter)]
57pub enum UserPermissions {
58    CheatsAccess,
59    LogOutAccess,
60}
61
62impl UserPermissions {
63    /// All permission variants. Admin users receive this full list so new variants are included automatically.
64    pub fn all() -> Vec<Self> {
65        Self::iter().collect()
66    }
67}
68
69#[derive(Clone, PartialEq, Eq, Default, Debug, Tsify, Serialize, Deserialize, JsonSchema)]
70#[tsify(into_wasm_abi)]
71pub struct OverlordState {
72    pub character_state: CharacterState,
73    pub blocked_character_ids: Vec<uuid::Uuid>,
74    /// Persistent global side and gauge shared by every flip equipment slot.
75    pub flip_state: FlipState,
76    pub active_fight: Option<ActiveFight>,
77    pub pvp_state: Option<PVPState>,
78    pub connection_store: HashMap<String, i64>,
79    pub quest_groups: QuestsGroups,
80    pub incoming_gifts: Vec<Gift>,
81    pub incoming_mails: Vec<Mail>,
82    pub patron: Option<Patron>,
83    pub referral_daily_reward_claimed: Option<bool>,
84    pub auto_chest: AutoChest,
85    pub arena: Arena,
86    pub dungeons: Dungeons,
87    pub offers_info: OffersInfo,
88    pub permissions: Vec<UserPermissions>,
89    pub party: Party,
90    pub progress_pass: ProgressPassState,
91    /// Authoritative unclaimed Daily/Weekly rating reward flags for every
92    /// rating type. Hydrated from entitlement storage and changed by server
93    /// events; the client never derives these flags from local time or rank.
94    pub rating_reward_availability: Vec<RatingRewardAvailability>,
95    /// Per-source damage/heal breakdown of the LAST completed fight — campaign
96    /// stage, arena match or dungeon run alike. Written once, in
97    /// `handle_end_fight`, and replaced by the next fight's summary.
98    ///
99    /// A state field rather than an `EndFight` payload for two reasons: it
100    /// survives `StateResync` (a carried-over one-shot event does not), and the
101    /// documented within-tick ordering — the state patch applies on the client
102    /// BEFORE that tick's events reach handlers — means it is already readable
103    /// when `EndFight` arrives, so the existing event doubles as the
104    /// "breakdown is ready" signal with no race.
105    ///
106    /// Not persisted: storage hydrates `None`, like `active_fight`.
107    pub last_fight_breakdown: Option<FightBreakdown>,
108}
109
110impl State for OverlordState {
111    /// Compares version from database with local
112    fn cmp_db_updated(&self, db_updated: &Self) -> bool {
113        self.character_state.character == db_updated.character_state.character
114            && self.character_state.inventory == db_updated.character_state.inventory
115    }
116
117    fn is_ticker_paused(&self) -> bool {
118        self.active_fight.as_ref().is_some_and(|fight| fight.paused)
119    }
120}
121
122impl OverlordState {
123    pub fn claim_vassal_reward(
124        &mut self,
125        vassal_id: uuid::Uuid,
126        claim_time: chrono::DateTime<chrono::Utc>,
127    ) -> anyhow::Result<()> {
128        match self
129            .character_state
130            .vassals
131            .iter_mut()
132            .find(|vassal| vassal.character_id == vassal_id)
133        {
134            Some(vassal) => {
135                vassal.claim_reward(claim_time);
136                Ok(())
137            }
138            None => anyhow::bail!("No vassal with given id={}", vassal_id),
139        }
140    }
141
142    pub fn claim_suzerain_reward(
143        &mut self,
144        claim_time: chrono::DateTime<chrono::Utc>,
145    ) -> anyhow::Result<()> {
146        let Some(suzerain) = self.character_state.suzerain.as_mut() else {
147            anyhow::bail!("No suzerain to update")
148        };
149        suzerain.claim_reward(claim_time);
150        Ok(())
151    }
152
153    pub fn compute_description_values_for_ability(
154        &self,
155        ability: &Ability,
156        behaviors: &BehaviorRegistry,
157        config: &GameConfig,
158    ) -> Vec<f64> {
159        let Some(template) = config.ability_template(ability.template_id) else {
160            tracing::error!(
161                "Failed to get template for ability template_id={}",
162                ability.template_id
163            );
164            return vec![];
165        };
166
167        let Some(ref description_script) = template.description_values_script else {
168            return vec![];
169        };
170
171        match crate::behaviors::ui_values::description_values(
172            &crate::behaviors::ui_values::DescriptionValuesCtx {
173                ability_level: ability.level,
174                ability_template_id: ability.template_id,
175                script: description_script,
176                config,
177                lookups: behaviors.lookups(),
178            },
179        ) {
180            Ok(values) => values,
181            Err(err) => {
182                tracing::error!("Error computing description values: {err}");
183                vec![]
184            }
185        }
186    }
187
188    pub fn compute_description_values_for_talent(
189        &self,
190        talent_template: &essences::talent_tree::TalentTemplate,
191        level: i64,
192    ) -> Vec<f64> {
193        let Some(ref description_script) = talent_template.description_values_script else {
194            return vec![];
195        };
196
197        match crate::behaviors::ui_values::talent_description_values(
198            &crate::behaviors::ui_values::TalentDescriptionValuesCtx {
199                talent_level: level,
200                script: description_script,
201            },
202        ) {
203            Ok(values) => values,
204            Err(err) => {
205                tracing::error!("Error computing talent description values: {err}");
206                vec![]
207            }
208        }
209    }
210
211    pub fn compute_description_values_template(
212        &self,
213        ability_template: &AbilityTemplate,
214        behaviors: &BehaviorRegistry,
215        config: &GameConfig,
216    ) -> Vec<f64> {
217        let ability = Ability::from_template(ability_template, None, None);
218
219        self.compute_description_values_for_ability(&ability, behaviors, config)
220    }
221
222    pub fn compute_afk_reward(
223        &self,
224        game_config: &GameConfig,
225        behaviors: &BehaviorRegistry,
226    ) -> Vec<BundleElement> {
227        let mut elements = vec![];
228
229        let Some(bundle) = game_config.bundle(game_config.afk_rewards_settings.bundle_id) else {
230            tracing::error!(
231                "Couldn't find afk bundle with id = {}",
232                game_config.afk_rewards_settings.bundle_id
233            );
234            return vec![];
235        };
236
237        let now = ::time::utc_now();
238
239        for step in &bundle.steps {
240            elements.push(bundle_raw_afk_step_to_element(
241                step,
242                &self.character_state,
243                now,
244                // Ordinary-claim preview: a pending ad charge is part of what
245                // the player is about to receive.
246                true,
247                behaviors,
248                game_config,
249            ));
250        }
251
252        elements
253    }
254
255    pub fn compute_afk_instant_reward(
256        &self,
257        game_config: &GameConfig,
258        behaviors: &BehaviorRegistry,
259    ) -> Vec<BundleElement> {
260        let mut elements = vec![];
261
262        let Some(bundle) = game_config.bundle(game_config.afk_rewards_settings.bundle_id) else {
263            tracing::error!(
264                "Couldn't find afk bundle with id = {}",
265                game_config.afk_rewards_settings.bundle_id
266            );
267            return vec![];
268        };
269
270        let afk_settings = &game_config.afk_rewards_settings;
271        let duration_sec = afk_settings
272            .instant_reward_duration_sec
273            .min(afk_settings.max_possible_time_sec);
274        let fake_now = self.character_state.character.last_afk_reward_claimed_at
275            + chrono::Duration::seconds(duration_sec as i64);
276
277        for step in &bundle.steps {
278            elements.push(bundle_raw_afk_step_to_element(
279                step,
280                &self.character_state,
281                fake_now,
282                // Instant grant: the pending charge is neither spent nor
283                // applied here (BAL-016).
284                false,
285                behaviors,
286                game_config,
287            ));
288        }
289
290        elements
291    }
292
293    pub fn compute_bundle_reward(
294        &self,
295        game_config: &GameConfig,
296        behaviors: &BehaviorRegistry,
297        bundle_id: BundleId,
298    ) -> Vec<BundleElement> {
299        let mut elements = vec![];
300
301        let Some(bundle) = game_config.bundle(bundle_id) else {
302            tracing::error!(
303                "Couldn't find bundle with id = {}",
304                game_config.afk_rewards_settings.bundle_id
305            );
306            return vec![];
307        };
308
309        for step in &bundle.steps {
310            elements.push(bundle_raw_step_to_element(
311                step,
312                &self.character_state,
313                behaviors,
314                game_config,
315            ));
316        }
317
318        elements
319    }
320
321    pub fn calculate_item_power(
322        &self,
323        item: Item,
324        game_config: &GameConfig,
325        behaviors: &BehaviorRegistry,
326    ) -> anyhow::Result<i64> {
327        Self::calculate_item_power_for_character_state(
328            &self.character_state,
329            item,
330            game_config,
331            behaviors,
332        )
333    }
334
335    /// Item power against an arbitrary character rather than the local player.
336    /// Item power is loadout-relative (the marginal power an item adds to a
337    /// character's equipped set), so viewing another player's item — e.g. in
338    /// their profile — must evaluate it against that player's own state.
339    pub fn calculate_item_power_for_character_state(
340        character_state: &CharacterState,
341        item: Item,
342        game_config: &GameConfig,
343        behaviors: &BehaviorRegistry,
344    ) -> anyhow::Result<i64> {
345        crate::behaviors::power::item_power(&crate::behaviors::power::ItemPowerCtx {
346            character: character_state,
347            item: &item,
348            config: game_config,
349            lookups: behaviors.lookups(),
350        })
351    }
352
353    /// `character.power` recomputed with inventory item `item_id`
354    /// hypothetically equipped: applies the same `is_equipped` mutation as
355    /// `handle_player_equip_item` to a copy of the character state and runs the
356    /// authoritative full-aggregation power path
357    /// ([`crate::behaviors::power::character_power`]). The `item_power`
358    /// marginal path is NOT equivalent: it composes attrs from level+items+pets
359    /// only (no class/talents/statue, no armor deflation) and drifts from the
360    /// authoritative value under the nonlinear power formula.
361    ///
362    /// Evaluating BOTH sides of a gear comparison through this function makes
363    /// stale `is_equipped` flags (an optimistic equip whose patch is still in
364    /// flight) cancel out of the difference.
365    pub fn calculate_power_with_item_equipped(
366        &self,
367        item_id: Uuid,
368        game_config: &GameConfig,
369        behaviors: &BehaviorRegistry,
370    ) -> anyhow::Result<i64> {
371        let item = self
372            .character_state
373            .inventory
374            .iter()
375            .find(|item| item.id == item_id)
376            .ok_or_else(|| anyhow::anyhow!("item {item_id} not found in inventory"))?;
377
378        self.calculate_power_with_candidate_equipped(item, game_config, behaviors)
379    }
380
381    /// [`Self::calculate_power_with_item_equipped`] for a candidate that need
382    /// not be in the inventory yet — the auto-chest filter ranks freshly
383    /// dropped items before they are appended to `character_state.inventory`,
384    /// so it cannot look them up by id.
385    pub fn calculate_power_with_candidate_equipped(
386        &self,
387        candidate: &Item,
388        game_config: &GameConfig,
389        behaviors: &BehaviorRegistry,
390    ) -> anyhow::Result<i64> {
391        let equipment_slot_key = candidate.equipment_slot_key();
392
393        let mut character_state = self.character_state.clone();
394        for inv_item in &mut character_state.inventory {
395            if inv_item.equipment_slot_key() == equipment_slot_key {
396                inv_item.is_equipped = false;
397            }
398        }
399
400        match character_state
401            .inventory
402            .iter_mut()
403            .find(|inv_item| inv_item.id == candidate.id)
404        {
405            Some(inv_item) => inv_item.is_equipped = true,
406            None => {
407                let mut candidate = candidate.clone();
408                candidate.is_equipped = true;
409                character_state.inventory.push(candidate);
410            }
411        }
412
413        crate::behaviors::power::character_power(&crate::behaviors::power::CharacterPowerCtx {
414            character: &character_state,
415            config: game_config,
416            lookups: behaviors.lookups(),
417        })
418    }
419
420    /// `character.power` for the loadout exactly as it stands — the baseline
421    /// every auto-chest comparison is made against.
422    pub fn calculate_current_power(
423        &self,
424        game_config: &GameConfig,
425        behaviors: &BehaviorRegistry,
426    ) -> anyhow::Result<i64> {
427        crate::behaviors::power::character_power(&crate::behaviors::power::CharacterPowerCtx {
428            character: &self.character_state,
429            config: game_config,
430            lookups: behaviors.lookups(),
431        })
432    }
433
434    /// The two power maps [`essences::autochest::filter_items`] compares.
435    ///
436    /// Both sides are scored as "character power with that item equipped"
437    /// through the authoritative aggregation — the SAME comparison the client
438    /// makes in `IsItemUpgrade` and on the gear-compare cards. The marginal
439    /// `item_power` category must not be used here: it composes attrs from
440    /// level+items+pets only (no class/talents/statue, no armor deflation) and
441    /// `power_from_attrs` is nonlinear, so the two paths genuinely rank
442    /// same-slot items differently — e.g. a `+crit_chance` item is worth
443    /// nothing once class and talents have pushed the clamped crit term to its
444    /// cap, which the marginal baseline never sees.
445    ///
446    /// Every occupied slot maps to the same baseline: hypothetically
447    /// re-equipping an already-equipped item is a no-op, so "power with the
448    /// equipped item" IS current power. One aggregation covers them all
449    /// instead of one per equipped item.
450    pub fn auto_chest_power_maps(
451        &self,
452        candidates: &[Item],
453        game_config: &GameConfig,
454        behaviors: &BehaviorRegistry,
455    ) -> anyhow::Result<(
456        HashMap<Uuid, i64>,
457        HashMap<essences::items::EquipmentSlotKey, i64>,
458    )> {
459        let mut candidate_powers = HashMap::new();
460        for candidate in candidates {
461            let power =
462                self.calculate_power_with_candidate_equipped(candidate, game_config, behaviors)?;
463            candidate_powers.insert(candidate.id, power);
464        }
465
466        let baseline = self.calculate_current_power(game_config, behaviors)?;
467        let mut equipped_powers = HashMap::new();
468        for item in &self.character_state.inventory {
469            if item.is_equipped {
470                equipped_powers.insert(item.equipment_slot_key(), baseline);
471            }
472        }
473
474        Ok((candidate_powers, equipped_powers))
475    }
476
477    /// Predicted `character.power` change from equipping inventory item
478    /// `item_id` ([`Self::calculate_power_with_item_equipped`] minus the stored
479    /// authoritative power), so a client-side prediction reconciles exactly
480    /// against the server's recalculated power.
481    pub fn calculate_equip_power_delta(
482        &self,
483        item_id: Uuid,
484        game_config: &GameConfig,
485        behaviors: &BehaviorRegistry,
486    ) -> anyhow::Result<i64> {
487        let power_after =
488            self.calculate_power_with_item_equipped(item_id, game_config, behaviors)?;
489        Ok(power_after - self.character_state.character.power)
490    }
491
492    /// Full-path item power for inventory item `item_id`: `character.power`
493    /// with the item equipped minus `character.power` with the item's
494    /// equipment slot empty, both through the authoritative aggregation
495    /// ([`crate::behaviors::power::character_power`]). Unlike the marginal
496    /// `item_power` category (level+items+pets only), two of these share the
497    /// slot-empty baseline, so their difference equals
498    /// [`Self::calculate_equip_power_delta`] exactly — gear-compare card
499    /// scores stay arithmetically consistent with the displayed delta.
500    pub fn calculate_item_power_full(
501        &self,
502        item_id: Uuid,
503        game_config: &GameConfig,
504        behaviors: &BehaviorRegistry,
505    ) -> anyhow::Result<i64> {
506        let equipment_slot_key = self
507            .character_state
508            .inventory
509            .iter()
510            .find(|item| item.id == item_id)
511            .map(Item::equipment_slot_key)
512            .ok_or_else(|| anyhow::anyhow!("item {item_id} not found in inventory"))?;
513
514        let power_with =
515            self.calculate_power_with_item_equipped(item_id, game_config, behaviors)?;
516
517        let mut character_state = self.character_state.clone();
518        for inv_item in &mut character_state.inventory {
519            if inv_item.equipment_slot_key() == equipment_slot_key {
520                inv_item.is_equipped = false;
521            }
522        }
523        let power_slot_empty = crate::behaviors::power::character_power(
524            &crate::behaviors::power::CharacterPowerCtx {
525                character: &character_state,
526                config: game_config,
527                lookups: behaviors.lookups(),
528            },
529        )?;
530
531        Ok(power_with - power_slot_empty)
532    }
533
534    pub fn get_active_fight_player(&self) -> Option<&Entity> {
535        self.active_fight
536            .as_ref()
537            .and_then(|fight| fight.get_player())
538    }
539
540    pub fn get_active_fight_player_mut(&mut self) -> Option<&mut Entity> {
541        self.active_fight
542            .as_mut()
543            .and_then(|fight| fight.get_player_mut())
544    }
545}