Skip to main content

hypixel/util/
networth.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::models::skyblock::Bazaar;
6
7/// A lookup of unit prices keyed by SkyBlock item id, assembled from market data.
8#[derive(Debug, Clone, Default)]
9pub struct PriceBook {
10    prices: HashMap<String, f64>,
11}
12
13impl PriceBook {
14    /// Create an empty price book.
15    pub fn new() -> Self {
16        Self::default()
17    }
18
19    /// Seed unit prices from a bazaar snapshot, using each product's instant-sell
20    /// (buy-order) price. Bazaar ids are uppercased to match item ids.
21    pub fn with_bazaar(mut self, bazaar: &Bazaar) -> Self {
22        for (id, product) in &bazaar.products {
23            if let Some(status) = &product.quick_status {
24                self.prices.insert(id.to_uppercase(), status.buy_price);
25            }
26        }
27        self
28    }
29
30    /// Overlay lowest-BIN prices (keyed by item id). BIN prices only fill items
31    /// that have no price yet; existing bazaar prices take precedence.
32    pub fn with_lowest_bin(mut self, lowest_bin: &HashMap<String, i64>) -> Self {
33        for (id, price) in lowest_bin {
34            self.prices
35                .entry(id.to_uppercase())
36                .or_insert(*price as f64);
37        }
38        self
39    }
40
41    /// Insert or override a single price.
42    pub fn insert(&mut self, item_id: impl Into<String>, price: f64) {
43        self.prices.insert(item_id.into().to_uppercase(), price);
44    }
45
46    /// Look up the unit price for an item id.
47    pub fn price(&self, item_id: &str) -> Option<f64> {
48        self.prices.get(&item_id.to_uppercase()).copied()
49    }
50
51    /// The assembled unit prices, keyed by uppercased item id.
52    pub fn prices(&self) -> &HashMap<String, f64> {
53        &self.prices
54    }
55}
56
57/// One item stack to be valued.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct ItemStack {
60    /// SkyBlock item id (e.g. `HYPERION`).
61    pub item_id: String,
62    /// Number of items in the stack.
63    pub count: i64,
64}
65
66/// The valuation of a single [`ItemStack`].
67#[derive(Debug, Clone, PartialEq, Serialize)]
68pub struct ItemValuation {
69    pub item_id: String,
70    pub count: i64,
71    pub unit_price: f64,
72    pub total: f64,
73}
74
75/// An itemized networth estimate.
76#[derive(Debug, Clone, Default, PartialEq, Serialize)]
77pub struct Networth {
78    /// Summed value of every priced stack.
79    pub total: f64,
80    /// Per-stack valuations that were successfully priced.
81    pub items: Vec<ItemValuation>,
82    /// Item ids that had no price in the [`PriceBook`].
83    pub unpriced: Vec<String>,
84}
85
86/// Value-bearing upgrades applied to an item, read from its `ExtraAttributes`.
87#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
88pub struct ItemModifiers {
89    /// Whether a recombobulator was applied (`rarity_upgrades`).
90    #[serde(default)]
91    pub recombobulated: bool,
92    /// Hot potato books applied; values above 10 are fuming potato books.
93    #[serde(default)]
94    pub hot_potato_books: i64,
95    /// Art of War books applied.
96    #[serde(default)]
97    pub art_of_war: i64,
98    /// Enchantments and their levels (e.g. `sharpness` → 7).
99    #[serde(default)]
100    pub enchantments: HashMap<String, i64>,
101}
102
103/// An [`ItemStack`] together with its value-bearing modifiers.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct DetailedItemStack {
106    pub item_id: String,
107    pub count: i64,
108    #[serde(default)]
109    pub modifiers: ItemModifiers,
110}
111
112/// The valuation of a single [`DetailedItemStack`].
113#[derive(Debug, Clone, PartialEq, Serialize)]
114pub struct DetailedItemValuation {
115    pub item_id: String,
116    pub count: i64,
117    /// Value of the bare items (unit price × count).
118    pub base_value: f64,
119    /// Added value of priced modifiers (recomb, potato books, enchantments...).
120    pub modifiers_value: f64,
121    pub total: f64,
122}
123
124/// An itemized networth estimate including modifier value.
125#[derive(Debug, Clone, Default, PartialEq, Serialize)]
126pub struct DetailedNetworth {
127    pub total: f64,
128    pub items: Vec<DetailedItemValuation>,
129    /// Item ids that had no base price in the [`PriceBook`].
130    pub unpriced: Vec<String>,
131}
132
133/// Estimate networth including modifier value against a [`PriceBook`].
134///
135/// On top of the base item price, this values recombobulators
136/// (`RECOMBOBULATOR_3000`), hot/fuming potato books (`HOT_POTATO_BOOK`,
137/// `FUMING_POTATO_BOOK`), Art of War books (`THE_ART_OF_WAR`), and
138/// enchantments (bazaar `ENCHANTMENT_<NAME>_<LEVEL>` products). Modifiers
139/// whose components have no price contribute nothing rather than failing.
140/// Dungeon stars and gemstones are not valued.
141pub fn value_items_detailed(stacks: &[DetailedItemStack], prices: &PriceBook) -> DetailedNetworth {
142    let mut networth = DetailedNetworth::default();
143    for stack in stacks {
144        let Some(unit_price) = prices.price(&stack.item_id) else {
145            networth.unpriced.push(stack.item_id.clone());
146            continue;
147        };
148        let m = &stack.modifiers;
149        let mut modifiers_value = 0.0;
150        if m.recombobulated {
151            modifiers_value += prices.price("RECOMBOBULATOR_3000").unwrap_or(0.0);
152        }
153        if m.hot_potato_books > 0 {
154            let plain = m.hot_potato_books.min(10);
155            let fuming = (m.hot_potato_books - 10).max(0);
156            modifiers_value += plain as f64 * prices.price("HOT_POTATO_BOOK").unwrap_or(0.0);
157            modifiers_value += fuming as f64 * prices.price("FUMING_POTATO_BOOK").unwrap_or(0.0);
158        }
159        if m.art_of_war > 0 {
160            modifiers_value += m.art_of_war as f64 * prices.price("THE_ART_OF_WAR").unwrap_or(0.0);
161        }
162        for (enchant, level) in &m.enchantments {
163            let id = format!("ENCHANTMENT_{}_{}", enchant.to_uppercase(), level);
164            modifiers_value += prices.price(&id).unwrap_or(0.0);
165        }
166
167        let base_value = unit_price * stack.count as f64;
168        let total = base_value + modifiers_value;
169        networth.total += total;
170        networth.items.push(DetailedItemValuation {
171            item_id: stack.item_id.clone(),
172            count: stack.count,
173            base_value,
174            modifiers_value,
175            total,
176        });
177    }
178    networth
179}
180
181/// Estimate the networth of a set of item stacks against a [`PriceBook`].
182pub fn value_items(stacks: &[ItemStack], prices: &PriceBook) -> Networth {
183    let mut networth = Networth::default();
184    for stack in stacks {
185        match prices.price(&stack.item_id) {
186            Some(unit_price) => {
187                let total = unit_price * stack.count as f64;
188                networth.total += total;
189                networth.items.push(ItemValuation {
190                    item_id: stack.item_id.clone(),
191                    count: stack.count,
192                    unit_price,
193                    total,
194                });
195            }
196            None => networth.unpriced.push(stack.item_id.clone()),
197        }
198    }
199    networth
200}
201
202/// Extract item stacks (SkyBlock id + count) from a decoded inventory NBT value.
203///
204/// Reads the standard `{ "i": [ { "Count", "tag": { "ExtraAttributes": { "id" } } } ] }`
205/// layout; empty slots and items without a SkyBlock id are skipped.
206#[cfg(feature = "nbt")]
207pub fn extract_item_stacks(nbt: &fastnbt::Value) -> Vec<ItemStack> {
208    extract_item_stacks_detailed(nbt)
209        .into_iter()
210        .map(|stack| ItemStack {
211            item_id: stack.item_id,
212            count: stack.count,
213        })
214        .collect()
215}
216
217/// Extract item stacks with their value-bearing modifiers from a decoded
218/// inventory NBT value.
219///
220/// Like [`extract_item_stacks`], but also reads recombobulators, potato
221/// books, Art of War books, and enchantments out of each item's
222/// `ExtraAttributes` for use with [`value_items_detailed`].
223#[cfg(feature = "nbt")]
224pub fn extract_item_stacks_detailed(nbt: &fastnbt::Value) -> Vec<DetailedItemStack> {
225    use fastnbt::Value;
226
227    fn as_compound(v: &Value) -> Option<&std::collections::HashMap<String, Value>> {
228        match v {
229            Value::Compound(map) => Some(map),
230            _ => None,
231        }
232    }
233
234    fn as_int(v: &Value) -> Option<i64> {
235        match v {
236            Value::Byte(n) => Some(*n as i64),
237            Value::Short(n) => Some(*n as i64),
238            Value::Int(n) => Some(*n as i64),
239            Value::Long(n) => Some(*n),
240            _ => None,
241        }
242    }
243
244    let items = match nbt {
245        Value::Compound(root) => match root.get("i") {
246            Some(Value::List(list)) => list,
247            _ => return Vec::new(),
248        },
249        Value::List(list) => list,
250        _ => return Vec::new(),
251    };
252
253    let mut stacks = Vec::new();
254    for entry in items {
255        let Some(item) = as_compound(entry) else {
256            continue;
257        };
258        let count = item.get("Count").and_then(as_int).unwrap_or(1);
259        let Some(attrs) = as_compound(item.get("tag").unwrap_or(&Value::Byte(0)))
260            .and_then(|tag| as_compound(tag.get("ExtraAttributes").unwrap_or(&Value::Byte(0))))
261        else {
262            continue;
263        };
264        let Some(Value::String(item_id)) = attrs.get("id") else {
265            continue;
266        };
267
268        let mut modifiers = ItemModifiers {
269            recombobulated: attrs.get("rarity_upgrades").and_then(as_int).unwrap_or(0) > 0,
270            hot_potato_books: attrs.get("hot_potato_count").and_then(as_int).unwrap_or(0),
271            art_of_war: attrs.get("art_of_war_count").and_then(as_int).unwrap_or(0),
272            ..ItemModifiers::default()
273        };
274        if let Some(enchants) = attrs.get("enchantments").and_then(as_compound) {
275            for (name, level) in enchants {
276                if let Some(level) = as_int(level) {
277                    modifiers.enchantments.insert(name.clone(), level);
278                }
279            }
280        }
281
282        stacks.push(DetailedItemStack {
283            item_id: item_id.clone(),
284            count,
285            modifiers,
286        });
287    }
288    stacks
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn detailed_valuation_prices_modifiers() {
297        let mut book = PriceBook::new();
298        book.insert("ASPECT_OF_THE_END", 100_000.0);
299        book.insert("RECOMBOBULATOR_3000", 8_000_000.0);
300        book.insert("HOT_POTATO_BOOK", 80_000.0);
301        book.insert("FUMING_POTATO_BOOK", 1_200_000.0);
302        book.insert("ENCHANTMENT_SHARPNESS_6", 500_000.0);
303
304        let stack = DetailedItemStack {
305            item_id: "ASPECT_OF_THE_END".into(),
306            count: 1,
307            modifiers: ItemModifiers {
308                recombobulated: true,
309                hot_potato_books: 12,
310                art_of_war: 0,
311                enchantments: [("sharpness".to_string(), 6i64)].into_iter().collect(),
312            },
313        };
314        let nw = value_items_detailed(&[stack], &book);
315        assert_eq!(nw.items.len(), 1);
316        let expected_modifiers = 8_000_000.0 + 10.0 * 80_000.0 + 2.0 * 1_200_000.0 + 500_000.0;
317        assert!((nw.items[0].modifiers_value - expected_modifiers).abs() < 1e-6);
318        assert!((nw.total - (100_000.0 + expected_modifiers)).abs() < 1e-6);
319    }
320
321    #[test]
322    fn values_known_items_and_tracks_unpriced() {
323        let mut book = PriceBook::new();
324        book.insert("HYPERION", 1_000_000.0);
325        let stacks = vec![
326            ItemStack {
327                item_id: "hyperion".into(),
328                count: 2,
329            },
330            ItemStack {
331                item_id: "MYSTERY".into(),
332                count: 1,
333            },
334        ];
335        let nw = value_items(&stacks, &book);
336        assert_eq!(nw.total, 2_000_000.0);
337        assert_eq!(nw.items.len(), 1);
338        assert_eq!(nw.unpriced, vec!["MYSTERY".to_string()]);
339    }
340}