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