essences/
autochest.rs

1use crate::gatings::AutoChestGatings;
2use crate::prelude::*;
3
4use crate::items::Attribute;
5use crate::items::AttributeId;
6use crate::items::Item;
7use crate::items::ItemRarity;
8use crate::items::ItemRarityId;
9
10#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
11pub struct AutoChest {
12    pub active_filter: Option<AutoChestFilter>,
13    pub filters: Vec<AutoChestFilter>,
14    pub power_compare_enabled: bool,
15    pub batch_size: i64,
16}
17
18impl Default for AutoChest {
19    fn default() -> Self {
20        Self {
21            active_filter: None,
22            filters: Vec::new(),
23            power_compare_enabled: true,
24            batch_size: 0,
25        }
26    }
27}
28
29#[declare]
30pub type AutoChestFilterId = Uuid;
31
32#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
33pub struct AutoChestFilter {
34    pub id: AutoChestFilterId,
35    pub name: String,
36    pub rarity_id: Option<ItemRarityId>,
37    pub guaranteed_attribute_enabled: bool,
38    pub guaranteed_attribute_id: Option<AttributeId>,
39    pub additional_attribute_enabled: bool,
40    pub additional_attribute_ids: Vec<AttributeId>,
41}
42
43impl AutoChestFilter {
44    pub fn validate(
45        &self,
46        cur_chapter_level: i64,
47        character_level: i64,
48        attributes: &[Attribute],
49        autochest_gatings: &AutoChestGatings,
50    ) -> anyhow::Result<()> {
51        // Auto-chest unlock gates on CHARACTER LEVEL (rewards manual chest-opening
52        // → faster leveling → earlier auto-open). The filter sub-capabilities
53        // (rarity / guaranteed-stat) below stay chapter-gated — they have their
54        // own thresholds.
55        if character_level < autochest_gatings.autochest_button_unlock_character_level {
56            anyhow::bail!(
57                "Auto chest is not enabled at character_level = {}",
58                character_level
59            )
60        }
61
62        if self.rarity_id.is_some() && cur_chapter_level < autochest_gatings.rarity_filter_unlock {
63            anyhow::bail!(
64                "Rarity filter is not available on cur_chapter_level = {}",
65                cur_chapter_level
66            )
67        }
68
69        if let Some(guaranteed_attribute_id) = self.guaranteed_attribute_id {
70            if cur_chapter_level < autochest_gatings.guaranteed_stat_unlock {
71                anyhow::bail!(
72                    "Guaranteed stat filter is not available on cur_chapter_level = {}",
73                    cur_chapter_level
74                )
75            }
76
77            let Some(guaranteed_attr) = attributes
78                .iter()
79                .find(|attr| attr.id == guaranteed_attribute_id)
80            else {
81                anyhow::bail!(
82                    "Tried finding guaranteed_attribute with id = {}, but didn't find it in config",
83                    guaranteed_attribute_id
84                )
85            };
86
87            if !guaranteed_attr.is_ui_visible {
88                anyhow::bail!(
89                    "Provided guaranteed attribute = {:?}, shouldn't be in auto chest",
90                    guaranteed_attr
91                )
92            }
93        }
94
95        if !self.additional_attribute_ids.is_empty() {
96            if self.additional_attribute_ids.len() > 3 {
97                anyhow::bail!(
98                    "More additional attributes provided: {}, than allowed: 3",
99                    self.additional_attribute_ids.len(),
100                )
101            }
102
103            let required_chapter = match self.additional_attribute_ids.len() {
104                1 => autochest_gatings.first_additional_stat_unlock,
105                2 => autochest_gatings.second_additional_stat_unlock,
106                3 => autochest_gatings.third_additional_stat_unlock,
107                _ => unreachable!(),
108            };
109
110            if cur_chapter_level < required_chapter {
111                anyhow::bail!(
112                    "Additional stats filter with {} attribute(s) is not available on cur_chapter_level = {}",
113                    self.additional_attribute_ids.len(),
114                    cur_chapter_level
115                )
116            }
117
118            for additional_attr_id in &self.additional_attribute_ids {
119                let Some(additional_attr) = attributes
120                    .iter()
121                    .find(|attr| attr.id == *additional_attr_id)
122                else {
123                    anyhow::bail!(
124                        "Tried finding additional_attribute with id = {}, but didn't find it in config",
125                        additional_attr_id
126                    )
127                };
128
129                if !additional_attr.is_ui_visible {
130                    anyhow::bail!(
131                        "Provided additional attribute = {:?}, shouldn't be in auto chest",
132                        additional_attr
133                    )
134                }
135            }
136        }
137
138        Ok(())
139    }
140}
141
142/// Split a batch of freshly dropped items into (sell, equip).
143///
144/// `item_powers` and `equipped_item_powers` must both be "character power with
145/// that item equipped" — see `OverlordState::auto_chest_power_maps`, which is
146/// the only supported way to build them. Mixing the marginal `item_power`
147/// category into either map silently re-introduces a ranking that disagrees
148/// with the client's own compare.
149pub fn filter_items(
150    items: &[Item],
151    filter: &Option<AutoChestFilter>,
152    power_compare_enabled: bool,
153    item_rarities: &Vec<ItemRarity>,
154    item_powers: &std::collections::HashMap<uuid::Uuid, i64>,
155    equipped_item_powers: &std::collections::HashMap<crate::items::EquipmentSlotKey, i64>,
156) -> anyhow::Result<(Vec<Item>, Vec<Item>)> {
157    let mut items_to_sell = vec![];
158    let mut items_to_equip = vec![];
159
160    let filter_rarity = if let Some(filter) = filter
161        && let Some(rarity_id) = filter.rarity_id
162    {
163        let Some(rarity) = item_rarities.iter().find(|r| r.id == rarity_id) else {
164            anyhow::bail!(
165                "Couldn't find filter rarity_id = {}, in provided item_rarities = {:?}",
166                rarity_id,
167                item_rarities
168            );
169        };
170        Some(rarity)
171    } else {
172        None
173    };
174
175    for item in items {
176        if let Some(filter_rarity) = filter_rarity
177            && item.rarity.order < filter_rarity.order
178        {
179            items_to_sell.push(item.clone());
180            continue;
181        }
182
183        if let Some(filter) = filter {
184            if filter.guaranteed_attribute_enabled
185                && let Some(guaranteed_attribute_id) = filter.guaranteed_attribute_id
186                && !item
187                    .attributes
188                    .iter()
189                    .any(|item_attr| item_attr.attr_id == guaranteed_attribute_id)
190            {
191                items_to_sell.push(item.clone());
192                continue;
193            }
194
195            if filter.additional_attribute_enabled
196                && !filter.additional_attribute_ids.is_empty()
197                && !filter.additional_attribute_ids.iter().any(|id| {
198                    item.attributes
199                        .iter()
200                        .any(|item_attr| *id == item_attr.attr_id)
201                })
202            {
203                items_to_sell.push(item.clone());
204                continue;
205            }
206        }
207
208        // Compare power against the equipped item in the same logical slot.
209        if power_compare_enabled
210            && let Some(&equipped_power) = equipped_item_powers.get(&item.equipment_slot_key())
211            && let Some(&item_power) = item_powers.get(&item.id)
212            && item_power < equipped_power
213        {
214            items_to_sell.push(item.clone());
215            continue;
216        }
217
218        items_to_equip.push(item.clone());
219    }
220
221    Ok((items_to_sell, items_to_equip))
222}