├── .gitignore ├── src ├── realm_reader.rs ├── thread_throttler.rs ├── battle_net_api_client.rs └── main.rs ├── Cargo.toml ├── templates ├── index.html ├── base.html └── prices.html ├── LICENSE ├── README.md └── catalog └── items.json /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | 4 | -------------------------------------------------------------------------------- /src/realm_reader.rs: -------------------------------------------------------------------------------- 1 | /// Used to read realm auction house data in a background 2 | /// thread periodically. 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "blood-money" 3 | version = "0.1.1" 4 | authors = ["hyena "] 5 | 6 | [dependencies] 7 | hyper = "0.9.13" 8 | iron = "0.4.0" 9 | regex = "0.1" 10 | router = "0.4.0" 11 | serde = "0.8" 12 | serde_derive = "0.8" 13 | serde_json = "0.8" 14 | scoped_threadpool = "0.1.7" 15 | tera = "0.4.1" 16 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Realm List{% endblock title %} 3 | {% block head %} 4 | {{ super() }} 5 | 16 | {% endblock head %} 17 | {% block content %} 18 |

Blood-Money {% if is_eu %}EU-{% endif %}Realm List

19 |

Select your realm from the listings below to view current 20 | [blood of sargeras] values. 21 |

22 |
23 |
24 | {% for realm in realms %} 25 | 26 | {% endfor %} 27 |
28 | {% endblock content %} 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 rodent.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% block head %} 4 | {% block title %}{% endblock title %} - Blood Money 5 | 38 | {% endblock head %} 39 | 40 | 41 |
{% block content %}{% endblock content %}
42 |
43 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A World of Warcraft webapp that helps players determine the best value for their bloods of sargeras and primal sargerite on their realm. 2 | 3 | Quickstart 4 | ---------- 5 | 1. Compile blood-money 6 | 2. Make an account on https://dev.battle.net/ and generate an 7 | API key 8 | 3. Run `blood-money (us|eu)` 9 | 4. Look at http://localhost:3000/blood-money or http://localhost:3001/blood-money-eu depending on 10 | how blood-money was launched. 11 | 12 | Todo 13 | ---- 14 | - Read token from config (or stick with commandline?) 15 | - Move these println's into a real logging system. 16 | - Save data between runs and use it when bringing the service 17 | back up. 18 | - The threading model is presently fairly serial and could be 19 | improved such that it was hurt less by stragglers or one 20 | buggy realm. 21 | - There's definitely some major CPU usage when the download 22 | is running. Possibly some dumb deserialization issue or 23 | sorting. 24 | - We should probably move most of the work to the background 25 | thread and make the web thread just essentially take a reader 26 | lock and clone some `Arc`'s 27 | - Add some more crafting options now that we have the 28 | infrastructure for that. 29 | 30 | Things that we might get to if this became more serious: 31 | - Currently does not respect changes in realm lists. 32 | Requires a restart to handle those changes. 33 | - Re-implement the `battle_net_api_client` into something 34 | robust: Use a modern version of hyper (means working with 35 | futures), flesh out all the method calls, move it into a 36 | lib. 37 | - Re-implement on rocket.rs instead of iron. 38 | 39 | License 40 | ------- 41 | Although I can't imagine someone else using this: MIT, of course. 42 | -------------------------------------------------------------------------------- /src/thread_throttler.rs: -------------------------------------------------------------------------------- 1 | use std::collections::VecDeque; 2 | use std::sync::{Condvar, Mutex}; 3 | use std::time::{Duration, Instant}; 4 | 5 | /// A ThreadThrottle is used to control the rate of 6 | /// thread progress, for example to limit the number 7 | /// of requests sent to a web API per second. 8 | /// If too many threads try to get through the 9 | /// ThreadThrottle at once, the additional ones 10 | /// will be slept until some time has passed. 11 | /// Note that the throttle will not necessarily let work 12 | /// through in FIFO order. 13 | pub struct ThreadThrottler { 14 | rate: u32, 15 | interval: Duration, 16 | 17 | action_history: Mutex>, 18 | cv: Condvar, 19 | } 20 | 21 | impl ThreadThrottler { 22 | /// Creates a new thread throttle that will let the 23 | /// specified number of calls pass within the provided 24 | /// interval. 25 | pub fn new(rate: u32, interval: Duration) -> ThreadThrottler { 26 | assert!(rate > 0, "Rate must be positive."); 27 | assert!(interval > Duration::new(0, 0), "Duration must be non-zero."); 28 | 29 | let mut tt = ThreadThrottler { 30 | rate: rate, 31 | interval: interval, 32 | 33 | action_history: Mutex::new(VecDeque::new()), 34 | cv: Condvar::new(), 35 | }; 36 | tt 37 | } 38 | 39 | /// Attempts to pass through the throttle. If there is 40 | /// sufficient capacity it will return immediately. 41 | /// Otherwise, the calling thread will block for some 42 | /// time before trying to pass through again. 43 | pub fn pass_through_or_block(&self) { 44 | let mut history = self.action_history.lock().unwrap(); 45 | prune_history(&mut history, (Instant::now() - self.interval)); 46 | 47 | while history.len() >= self.rate as usize { 48 | let minimum_sleep = (*history.get(0).unwrap() + self.interval) - Instant::now(); 49 | let (lock_result, _) = self.cv.wait_timeout(history, minimum_sleep).unwrap(); 50 | history = lock_result; 51 | prune_history(&mut history, (Instant::now() - self.interval)); 52 | } 53 | 54 | history.push_back(Instant::now()); 55 | } 56 | } 57 | 58 | /// Prunes a sorted history of events, cutting off those 59 | /// older than a cutoff. 60 | fn prune_history(history: &mut VecDeque, cutoff: Instant) { 61 | if history.is_empty() { 62 | return; 63 | } 64 | 65 | while !history.is_empty() && *history.front().unwrap() < cutoff { 66 | history.pop_front(); 67 | } 68 | } 69 | 70 | 71 | #[cfg(test)] 72 | mod tests { 73 | use std::time::{Duration, Instant}; 74 | 75 | use super::*; 76 | 77 | #[test] 78 | #[should_panic(expected = "Rate must be positive")] 79 | fn test_bad_rate() { 80 | let tt = ThreadThrottler::new(0, Duration::new(0, 1)); 81 | } 82 | 83 | #[test] 84 | fn test_basic_throttle() { 85 | // Let a thread through 1 time every 100 milliseconds. 86 | let tt = ThreadThrottler::new(1, Duration::new(0, 100_000_000)); 87 | let start_time = Instant::now(); 88 | for x in 0..11 { 89 | tt.pass_through_or_block(); 90 | } 91 | let run_time = Instant::now() - start_time; 92 | assert!(run_time > Duration::new(1, 0)); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /templates/prices.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}{{realm_name}} blood values{% endblock title %} 3 | {% block head %} 4 | {{ super() }} 5 | 55 | {% endblock head %} 56 | {% block content %} 57 |

Current values for {% if is_eu %}EU-{% endif %}{{realm_name}}

58 | {% if update_age == -1 %} 59 |

Still waiting on results for this realm.

60 | {% else %} 61 | Last updated {{update_age}} minute{% if update_age > 0 %}s{% endif %} ago. 62 |

Blood of Sargeras - Best investment is currently {{blood_price_rows.0.name}}

63 |
64 |
65 |
66 | {% for price_row in blood_price_rows %} 67 |
68 | 69 | {{price_row.name}} X {{price_row.quantity}} 70 | {% if price_row.subtext %} 71 |
72 | {{price_row.subtext}} 73 | {% endif %} 74 | {% if price_row.mats | length > 0 %} 75 |
76 | Crafting Mats:
77 | {% for mat in price_rows.mats %} 78 | {{mat.name}}: {{mat.gold}}g{{mat.silver}}s{{mat.copper}}c
79 | {% endfor %} 80 |
81 | {% endif %} 82 |
83 | {% endfor %} 84 |
85 |
86 | {% for price_row in blood_price_rows %} 87 |
88 |
89 | {{price_row.gold}}g{{price_row.silver}}s{{price_row.copper}}c 90 | {% if price_row.subtext %} 91 |
92 |   93 | {% endif %} 94 | {% if price_row.mats | length > 0 %} 95 |
96 |  
97 | {% for mat in price_rows.mats %} 98 |  
99 | {% endfor %} 100 |
101 | {% endif %} 102 |
103 | {% endfor %} 104 |
105 |
106 | 107 |

Primal Sargerite - Best investment is currently {{sargerite_price_rows.0.name}}

108 |
109 |
110 |
111 | {% for price_row in sargerite_price_rows %} 112 |
113 | 114 | {{price_row.name}} X {{price_row.quantity}} 115 | {% if price_row.subtext %} 116 |
117 | {{price_row.subtext}} 118 | {% endif %} 119 | {% if price_row.mats | length > 0 %} 120 |
121 | Crafting Mats:
122 | {% for mat in price_rows.mats %} 123 | {{mat.name}}: {{mat.gold}}g{{mat.silver}}s{{mat.copper}}c
124 | {% endfor %} 125 |
126 | {% endif %} 127 |
128 | {% endfor %} 129 |
130 |
131 | {% for price_row in sargerite_price_rows %} 132 |
133 |
134 | {{price_row.gold}}g{{price_row.silver}}s{{price_row.copper}}c 135 | {% if price_row.subtext %} 136 |
137 |   138 | {% endif %} 139 | {% if price_row.mats | length > 0 %} 140 |
141 |  
142 | {% for mat in price_rows.mats %} 143 |  
144 | {% endfor %} 145 |
146 | {% endif %} 147 |
148 | {% endfor %} 149 |
150 |
151 | {% endif %} 152 | {% endblock content %} 153 | -------------------------------------------------------------------------------- /catalog/items.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 124117, 4 | "name": "Lean Shank", 5 | "quantity": 10, 6 | "vendor_type": "blood" 7 | }, 8 | { 9 | "id": 124101, 10 | "name": "Aethril", 11 | "quantity": 10, 12 | "vendor_type": "blood" 13 | }, 14 | { 15 | "id": 124437, 16 | "name": "Shal'dorei Silk", 17 | "quantity": 10, 18 | "vendor_type": "blood" 19 | }, 20 | { 21 | "id": 124107, 22 | "name": "Cursed Queen Fish", 23 | "quantity": 10, 24 | "vendor_type": "blood" 25 | }, 26 | { 27 | "id": 124118, 28 | "name": "Fatty Bearsteak", 29 | "quantity": 10, 30 | "vendor_type": "blood" 31 | }, 32 | { 33 | "id": 124102, 34 | "name": "Dreamleaf", 35 | "quantity": 10, 36 | "vendor_type": "blood" 37 | }, 38 | { 39 | "id": 124113, 40 | "name": "Stonehide Leather", 41 | "quantity": 10, 42 | "vendor_type": "blood" 43 | }, 44 | { 45 | "id": 124108, 46 | "name": "Mossgill Perch", 47 | "quantity": 10, 48 | "vendor_type": "blood" 49 | }, 50 | { 51 | "id": 124119, 52 | "name": "Big Gamy Ribs", 53 | "quantity": 10, 54 | "vendor_type": "blood" 55 | }, 56 | { 57 | "id": 124103, 58 | "name": "Foxflower", 59 | "quantity": 10, 60 | "vendor_type": "blood" 61 | }, 62 | { 63 | "id": 124115, 64 | "name": "Stormscale", 65 | "quantity": 10, 66 | "vendor_type": "blood" 67 | }, 68 | { 69 | "id": 124109, 70 | "name": "Highmountain Salmon", 71 | "quantity": 10, 72 | "vendor_type": "blood" 73 | }, 74 | { 75 | "id": 124120, 76 | "name": "Leyblood", 77 | "quantity": 10, 78 | "vendor_type": "blood" 79 | }, 80 | { 81 | "id": 124104, 82 | "name": "Fjarnskaggl", 83 | "quantity": 10, 84 | "vendor_type": "blood" 85 | }, 86 | { 87 | "id": 124440, 88 | "name": "Arkhana", 89 | "quantity": 10, 90 | "vendor_type": "blood" 91 | }, 92 | { 93 | "id": 124110, 94 | "name": "Stormray", 95 | "quantity": 10, 96 | "vendor_type": "blood" 97 | }, 98 | { 99 | "id": 124121, 100 | "name": "Wildfowl Egg", 101 | "quantity": 10, 102 | "vendor_type": "blood" 103 | }, 104 | { 105 | "id": 124105, 106 | "name": "Starlight Rose", 107 | "quantity": 3, 108 | "vendor_type": "blood" 109 | }, 110 | { 111 | "id": 124441, 112 | "name": "Leylight Shard", 113 | "quantity": 3, 114 | "vendor_type": "blood" 115 | }, 116 | { 117 | "id": 124111, 118 | "name": "Runescale Koi", 119 | "quantity": 10, 120 | "vendor_type": "blood" 121 | }, 122 | { 123 | "id": 124439, 124 | "name": "Unbroken Tooth", 125 | "quantity": 20, 126 | "vendor_type": "blood" 127 | }, 128 | { 129 | "id": 123918, 130 | "name": "Leystone Ore", 131 | "quantity": 10, 132 | "vendor_type": "blood" 133 | }, 134 | { 135 | "id": 123919, 136 | "name": "Felslate", 137 | "quantity": 5, 138 | "vendor_type": "blood" 139 | }, 140 | { 141 | "id": 124112, 142 | "name": "Black Barracuda", 143 | "quantity": 10, 144 | "vendor_type": "blood" 145 | }, 146 | { 147 | "id": 142117, 148 | "name": "Potion of Prolonged Power", 149 | "quantity": 10, 150 | "vendor_type": "blood", 151 | "subtext": "Alchemy craftable" 152 | }, 153 | { 154 | "id": 151565, 155 | "name": "Astral Glory", 156 | "quantity": 10, 157 | "vendor_type": "sargerite" 158 | }, 159 | { 160 | "id": 151564, 161 | "name": "Empyrium", 162 | "quantity": 10, 163 | "vendor_type": "sargerite" 164 | }, 165 | { 166 | "id": 151566, 167 | "name": "Fiendish Leather", 168 | "quantity": 10, 169 | "vendor_type": "sargerite" 170 | }, 171 | { 172 | "id": 151567, 173 | "name": "Lightweave Cloth", 174 | "quantity": 10, 175 | "vendor_type": "sargerite" 176 | }, 177 | { 178 | "id": 151579, 179 | "name": "Labradorite", 180 | "quantity": 0.1, 181 | "vendor_type": "sargerite" 182 | }, 183 | { 184 | "id": 151722, 185 | "name": "Florid Malachite", 186 | "quantity": 0.1, 187 | "vendor_type": "sargerite" 188 | }, 189 | { 190 | "id": 151720, 191 | "name": "Chemirine", 192 | "quantity": 0.1, 193 | "vendor_type": "sargerite" 194 | }, 195 | { 196 | "id": 151718, 197 | "name": "Argulite", 198 | "quantity": 0.1, 199 | "vendor_type": "sargerite" 200 | }, 201 | { 202 | "id": 151721, 203 | "name": "Hesselian", 204 | "quantity": 0.1, 205 | "vendor_type": "sargerite" 206 | }, 207 | { 208 | "id": 151719, 209 | "name": "Lightsphene", 210 | "quantity": 0.1, 211 | "vendor_type": "sargerite" 212 | }, 213 | { 214 | "id": 152296, 215 | "name": "Primal Obliterum", 216 | "quantity": 1, 217 | "subtext": "Traded in Dalaran", 218 | "mats": [{ 219 | "id": 124125, 220 | "quantity": 1 221 | }], 222 | "vendor_type": "sargerite" 223 | }, 224 | { 225 | "id": 124125, 226 | "name": "Obliterum", 227 | "quantity": 1, 228 | "vendor_type": "reagent" 229 | } 230 | ] 231 | -------------------------------------------------------------------------------- /src/battle_net_api_client.rs: -------------------------------------------------------------------------------- 1 | //! A Module for accessing Blizzard's WoW API. 2 | //! The exposed functionality of this module has been structured 3 | //! around the particular needs of blood-money: Not all fields are 4 | //! represented and it's probably not generally useful. 5 | //! TODO: Add support for locales. 6 | extern crate hyper; 7 | extern crate serde_json; 8 | 9 | use std::collections::BTreeMap; 10 | use std::io::Read; 11 | use std::time::Duration; 12 | 13 | use hyper::client::{Client, Response}; 14 | use regex::bytes::Regex; 15 | use serde::de::Deserialize; 16 | use thread_throttler::ThreadThrottler; 17 | 18 | /// The content we care about in the realm status response. 19 | #[derive(Debug, Serialize, Deserialize)] 20 | pub struct RealmInfo { 21 | pub name: String, 22 | pub slug: String, 23 | pub connected_realms: Vec, 24 | } 25 | 26 | /// Content we care about in an item info response. 27 | #[derive(Debug, Deserialize)] 28 | pub struct ItemInfo { 29 | pub id: u64, 30 | pub name: String, 31 | pub icon: String, 32 | } 33 | 34 | /// Represents the reply from blizzard's auction data urls. 35 | #[derive(Debug, Deserialize)] 36 | struct AuctionListingsReply { 37 | realms: Vec>, // Can't re-use RealmInfo because no connected_realms. 38 | auctions: Vec, 39 | } 40 | 41 | /// Represents the JSON reply from the auction data status endpoint. 42 | #[derive(Debug, Deserialize)] 43 | #[allow(non_snake_case)] 44 | struct AuctionDataPointer { 45 | url: String, 46 | lastModified: u64, 47 | } 48 | 49 | #[derive(Debug, Deserialize)] 50 | struct AuctionDataReply { 51 | files: Vec, // Will always be 1 element. 52 | } 53 | 54 | /// The fields we care about in blizzard's auction reply. 55 | #[derive(Debug, Deserialize)] 56 | pub struct AuctionListing { 57 | pub item: u64, 58 | pub buyout: u64, 59 | pub quantity: u64, 60 | } 61 | 62 | #[derive(Clone, Copy, PartialEq)] 63 | pub enum Region { 64 | US, 65 | EU, 66 | } 67 | 68 | pub struct BattleNetApiClient<'a> { 69 | pub token: String, 70 | client: Client, 71 | tt: ThreadThrottler, 72 | api_host: &'a str, 73 | api_locale: &'a str, 74 | } 75 | 76 | impl<'a> BattleNetApiClient<'a> { 77 | pub fn new(token: &str, region: Region) -> BattleNetApiClient { 78 | let mut hyper_client = Client::new(); 79 | hyper_client.set_read_timeout(Some(Duration::from_secs(300))); 80 | 81 | BattleNetApiClient { 82 | token: token.to_owned(), 83 | client: hyper_client, 84 | tt: ThreadThrottler::new(100, Duration::new(1, 0)), 85 | api_host: match region { 86 | Region::US => "us.api.battle.net", 87 | Region::EU => "eu.api.battle.net", 88 | }, 89 | api_locale: match region { 90 | Region::US => "en_US", 91 | Region::EU => "en_GB", 92 | }, 93 | } 94 | } 95 | 96 | /// Try to retrieve something from the Blizzard API. Will retry indefinitely. 97 | /// Returns the body as a String. 98 | /// `task` will be used to generate error messages. 99 | fn make_blizzard_api_call(&self, url: &str, task: &str) -> String { 100 | let mut s = String::new(); 101 | let mut retries = 0; 102 | 103 | loop { 104 | let mut res: Response; 105 | retries += 1; 106 | 107 | self.tt.pass_through_or_block(); 108 | match self.client.get(url).send() { 109 | Ok(r) => res = r, 110 | Err(e) => { 111 | println!("Error downloading {}: {}. Retry {}.", task, e, retries); 112 | continue; 113 | }, 114 | } 115 | // TODO: 404 should really be handled differently here. Maybe make this return a Result? 116 | // That would let us account for unrecoverable errors. 117 | if res.status != hyper::Ok { 118 | println!("Error downloading {}: {}. Retry {}.", task, res.status, retries); 119 | continue; 120 | } 121 | match res.read_to_string(&mut s) { 122 | Ok(_) => (), 123 | Err(e) => { 124 | println!("Failed to process {}: {}. Retry {}.", task, e, retries); 125 | continue; 126 | }, 127 | } 128 | return s; 129 | } 130 | } 131 | 132 | /// Downloads a list of realms from the Blizzard API. 133 | /// Panics if the json response is malformed. 134 | pub fn get_realms(&self) -> Vec { 135 | let mut realm_data: BTreeMap> = serde_json::from_str(&self.make_blizzard_api_call( 136 | &format!("https://{}/wow/realm/status?locale={}&apikey={}", self.api_host, self.api_locale, self.token), "realm status") 137 | ).unwrap(); 138 | realm_data.remove("realms").expect("Malformed realm response.") 139 | } 140 | 141 | /// Downloads the auction listings for the specified realm, or None if the listings haven't 142 | /// been updated since `cutoff` or if the json response is illformed. 143 | pub fn get_auction_listings(&self, realm_slug: &str, cutoff: u64) -> Option<(u64, Vec)> { 144 | let mut auction_data_reply: AuctionDataReply; 145 | match serde_json::from_str(&self.make_blizzard_api_call( 146 | &format!("https://{}/wow/auction/data/{}?locale={}&apikey={}", self.api_host, realm_slug, self.api_locale, self.token), 147 | &format!("auction data for {}", realm_slug))) 148 | { 149 | Ok(reply) => auction_data_reply = reply, 150 | Err(e) => { 151 | println!("Bad json in auction pointer reply for {}: {}", realm_slug, e); 152 | return None; 153 | }, 154 | } 155 | let auction_data_pointer = auction_data_reply.files.pop().unwrap(); 156 | if auction_data_pointer.lastModified <= cutoff { 157 | return None; 158 | } 159 | 160 | let mut auction_data_str = self.make_blizzard_api_call(&auction_data_pointer.url, &format!("auction listings for {}", realm_slug)); 161 | // Auction data strings are especially problematic and often contain numerous invalid bytes in the "owner" and 162 | // "ownerRealm" fields. Unfortunately, String::from_utf8_lossy() doesn't appear sufficient to deal with this 163 | // so we use the heavy handed approach of a regex to rewrite these fields. 164 | // TODO: Make this a lazy_static!. 165 | let sanitize_re = Regex::new("\"owner\":\".*?\",\"ownerRealm\":\".*?\",\"bid").unwrap(); 166 | auction_data_str = String::from_utf8( 167 | sanitize_re.replace_all(auction_data_str.as_bytes(), 168 | &b"\"owner\":\"_\",\"ownerRealm\":\"_\",\"bid"[..]) 169 | ).unwrap(); 170 | match serde_json::from_str::(&auction_data_str) { 171 | Ok(auction_listings_data) => Some((auction_data_pointer.lastModified, auction_listings_data.auctions)), 172 | Err(e) => { 173 | println!("Error decoding json auction listings for {}: {}", realm_slug, e); 174 | None 175 | }, 176 | } 177 | } 178 | 179 | /// Helpler function to process a vec of RealmInfo's into vec's of slugs for 180 | /// connected realms. Connected realms share an auction house. 181 | pub fn process_connected_realms(realm_infos: &Vec) -> Vec> { 182 | let mut realm_sets: Vec> = realm_infos.into_iter().map(|r| 183 | r.connected_realms.clone() 184 | ).collect(); 185 | 186 | // This dedup logic relies on the ordering within a connected realms list being the same 187 | // for all realms in the list. 188 | realm_sets.sort_by(|a, b| a.iter().next().unwrap().cmp(b.iter().next().unwrap())); 189 | realm_sets.dedup(); 190 | return realm_sets; 191 | } 192 | 193 | /// Get info on an item. Panics on a malformed json response. 194 | pub fn get_item_info(&self, id: u64) -> ItemInfo { 195 | serde_json::from_str(&self.make_blizzard_api_call( 196 | &format!("https://{}/wow/item/{}?locale={}&apikey={}", self.api_host, id, self.api_locale, self.token), "item info") 197 | ).unwrap() 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(drain_filter, proc_macro, slice_patterns)] 2 | 3 | extern crate hyper; 4 | extern crate iron; 5 | extern crate regex; 6 | extern crate router; 7 | extern crate serde; 8 | #[macro_use] 9 | extern crate serde_derive; 10 | extern crate serde_json; 11 | extern crate scoped_threadpool; 12 | extern crate tera; 13 | 14 | use std::collections::{BTreeMap, HashMap}; 15 | use std::env; 16 | use std::sync::{Arc, RwLock}; 17 | use std::thread::sleep; 18 | use std::time::{Instant, Duration, SystemTime, UNIX_EPOCH}; 19 | 20 | use iron::headers::ContentType; 21 | use iron::prelude::*; 22 | use iron::status; 23 | use router::Router; 24 | use scoped_threadpool::Pool; 25 | use tera::{Context, Tera}; 26 | 27 | pub mod battle_net_api_client; 28 | pub mod thread_throttler; 29 | 30 | use battle_net_api_client::{AuctionListing, BattleNetApiClient, Region}; 31 | 32 | /// Represents a single option available for sale from the blood vendor. 33 | #[derive(Debug, Deserialize)] 34 | struct VendorItem { 35 | name: String, 36 | quantity: f64, 37 | id: u64, 38 | vendor_type: String, // TODO: This should really be an enum populated by a custom deserializer. 39 | subtext: Option, 40 | mats: Option>, 41 | } 42 | 43 | /// Some items we can 'buy' are actually crafted or traded with NPCs. This represents 44 | /// an ingredient in the recipe. 45 | #[derive(Debug, Deserialize)] 46 | struct CraftingComponent { 47 | id: u64, 48 | quantity: u64, 49 | } 50 | 51 | /// Value of an item on a realm. 52 | #[derive(Debug)] 53 | struct ItemValue { 54 | id: u64, 55 | value: u64, 56 | } 57 | 58 | /// The calculated values for items on a particular realm. 59 | #[derive(Debug)] 60 | struct CurrentRealmValues { 61 | last_update: u64, // The last time we got this info, as reported by the Blizzard API. 62 | value_map: Arc>, 63 | sargerite_item_values: Arc>, 64 | blood_item_values: Arc>, // Should be sorted by value. 65 | } 66 | 67 | /// All the data in a single row in our price list for a realm. 68 | #[derive(Debug, Serialize)] 69 | struct PriceRow { 70 | name: String, 71 | quantity: f64, 72 | icon: String, 73 | subtext: String, 74 | vendor_type: String, 75 | value_ratio: u64, 76 | gold: u64, 77 | silver: u64, 78 | copper: u64, 79 | mats: Vec, 80 | } 81 | 82 | #[derive(Debug, Serialize)] 83 | struct Material { 84 | name: String, 85 | gold: u64, 86 | silver: u64, 87 | copper: u64, 88 | } 89 | /// Number of threads to use when fetching auction house results. 90 | const NUM_AUCTION_DATA_THREADS: u32 = 5; 91 | 92 | /// Number of seconds to wait between fetching new auction results. 93 | const RESULT_FETCH_PERIOD: u64 = 60 * 30; 94 | 95 | /// Given a vec of auction listings for a realm and a map of the items we care about, 96 | /// returns a vec of (item_id, value) sorted by decreasing value, where value is 97 | /// based on the 5th percentile buyout price. 98 | fn calculate_auction_values(listings: &Vec, items: &HashMap) -> 99 | (HashMap, Vec, Vec) { 100 | // Calculate 5th percentiles for the items we care about. 101 | let mut price_points: BTreeMap> = BTreeMap::new(); 102 | for listing in listings { 103 | if items.contains_key(&listing.item) && listing.buyout > 0 { 104 | price_points.entry(listing.item).or_insert(Vec::new()).push((listing.quantity, listing.buyout / listing.quantity)); 105 | } 106 | } 107 | for quantities_and_buyouts in price_points.values_mut() { 108 | quantities_and_buyouts.sort_by_key(|a| a.1); // Sort by buyout price. 109 | } 110 | let total_item_quantities: BTreeMap = 111 | price_points.iter().map(|(k, v)| { 112 | (*k, v.iter().fold(0, |sum, quantity_and_buyout| sum + quantity_and_buyout.0)) 113 | }).collect(); 114 | let fifth_percentile_price_points: HashMap = 115 | price_points.iter().map(|(item_id, ref item_listings)| { 116 | let fifth_percentile_quantity = total_item_quantities.get(item_id).unwrap() / 20; 117 | let mut running_sum: u64 = 0; 118 | let fifth_percentile_listing = item_listings.iter().find(|&&(quantity, _)| { 119 | running_sum += quantity; 120 | running_sum >= fifth_percentile_quantity 121 | }).unwrap(); 122 | (*item_id, fifth_percentile_listing.1) 123 | }).collect(); 124 | let mut item_values: Vec = items.values().map(|item| { 125 | let mut value = *fifth_percentile_price_points.get(&item.id).unwrap_or(&0u64); 126 | value = match &item.mats { // Subtract the costs of mats if any. 127 | &Some(ref mats_list) => { 128 | // Sum up the prices. 129 | let mat_cost = mats_list.iter().map( 130 | |&ref x| x.quantity * fifth_percentile_price_points.get(&x.id).unwrap_or(&0u64) 131 | ).sum(); 132 | if value >= mat_cost { 133 | value - mat_cost 134 | } else { 135 | 0 136 | } 137 | }, 138 | &None => value 139 | }; 140 | ItemValue { 141 | id: item.id, 142 | value: match item.quantity { 143 | // A bit of a hack to avoid propagating floating point madness through the codebase: 144 | // Some items are 10 primal sargerite for 1 gem. All the others are whole numbers. So 145 | // we special case the former case as integer division. 146 | 0.1 => value / 10, 147 | _ => value * item.quantity as u64, 148 | } 149 | } 150 | }).collect(); 151 | item_values.sort_by_key(|item_value| !item_value.value); 152 | 153 | // NOTE: drain_filter() is a nightly only experimental API call that might break. 154 | let blood_item_values = item_values.drain_filter(|x| items.get(&x.id).unwrap().vendor_type.eq("blood")) 155 | .collect::>(); 156 | let sargerite_item_values = item_values.drain_filter(|x| items.get(&x.id).unwrap().vendor_type.eq("sargerite")) 157 | .collect::>(); 158 | 159 | (fifth_percentile_price_points, blood_item_values, sargerite_item_values) 160 | } 161 | 162 | /// Given a battle net Region, return the first path of the app's URL. 163 | fn app_url_for_region(region: &Region) -> &'static str { 164 | match region { 165 | &Region::US => "blood-money", 166 | &Region::EU => "blood-money-eu", 167 | } 168 | } 169 | 170 | fn main() { 171 | let token = match env::args().nth(1) { 172 | Some(token) => token, 173 | None => { 174 | println!("Usage: bloodmoney (us|eu)"); 175 | return; 176 | } 177 | }; 178 | let locale = match env::args().nth(2) { 179 | Some(ref s) if s == "us" => Region::US, 180 | Some(ref s) if s == "eu" => Region::EU, 181 | _ => { 182 | println!("Usage: bloodmoney (us|eu)"); 183 | return; 184 | } 185 | }; 186 | let client = Arc::new(BattleNetApiClient::new(&token, locale)); 187 | 188 | // Process our item options and grab their icon names. 189 | let items: Vec = serde_json::from_str(include_str!("../catalog/items.json")) 190 | .expect("Error reading items."); 191 | let item_id_map: Arc> = Arc::new(items.into_iter().map(|x| (x.id, x)).collect()); 192 | let item_icons: Arc> = Arc::new(item_id_map.keys().map(|&id| (id, client.get_item_info(id).icon)).collect()); 193 | 194 | // Get the list of realms and create an empty price map so we can render pages while 195 | // waiting for the auction results to be retrieved. 196 | let realms = Arc::new(client.get_realms()); 197 | let connected_realms = BattleNetApiClient::process_connected_realms(&realms); 198 | let price_map: Arc>> = 199 | Arc::new(realms.iter().map(|realm| (realm.slug.clone(), RwLock::new(CurrentRealmValues { 200 | last_update: 0, 201 | value_map: Arc::new(HashMap::new()), 202 | sargerite_item_values: Arc::new(Vec::new()), 203 | blood_item_values: Arc::new(Vec::new()), 204 | }))).collect()); 205 | 206 | // Set up our web-app. 207 | let tera = Arc::new(Tera::new("templates/**/*")); 208 | let mut router = Router::new(); 209 | { 210 | let realms = realms.clone(); 211 | let tera = tera.clone(); 212 | router.get(format!("/{}", app_url_for_region(&locale)), move |_: &mut Request| { 213 | let mut context = Context::new(); 214 | context.add("realms", &realms); 215 | context.add("is_eu", &(locale == Region::EU)); 216 | Ok(Response::with((ContentType::html().0, status::Ok, tera.render("index.html", context).unwrap()))) 217 | }, "index"); 218 | } 219 | { 220 | let price_map = price_map.clone(); 221 | let item_id_map = item_id_map.clone(); 222 | let realms = realms.clone(); 223 | let tera = tera.clone(); 224 | router.get(format!("/{}/:realm", app_url_for_region(&locale)), move |req : &mut Request| { 225 | let realm = req.extensions.get::().unwrap().find("realm").unwrap(); 226 | if let Some(realm_prices_lock) = price_map.get(realm) { 227 | let mut context = Context::new(); 228 | let realm_prices = realm_prices_lock.read().unwrap(); 229 | 230 | // Closure that processes the vectors of ItemValues into 231 | // the HTML that we need. 232 | // TODO: Things would actually be even cleaner probably if 233 | // we did all this work in the background thread; it doesn't 234 | // change per request. Then this router method would basically 235 | // just take a read lock on the values, clone some things, 236 | // and build the contexts. 237 | let make_price_rows = |x: &Vec| -> Vec { 238 | let highest_value = match x.first() { 239 | Some(item_value) => item_value.value, 240 | None => 0 241 | }; 242 | x.iter().map(|&ItemValue{id, value}| { 243 | let item_info = item_id_map.get(&id).unwrap(); 244 | let gold = value / (10_000); 245 | let silver = (value - gold * 10_000) / 100; 246 | let copper = value - gold * 10_000 - silver * 100; 247 | let value_ratio = match highest_value { 248 | 0u64 => 0u64, 249 | _ => value*100/highest_value, // Percentile! 250 | }; 251 | PriceRow { 252 | name: item_info.name.clone(), 253 | quantity: item_info.quantity, 254 | icon: item_icons.get(&id).unwrap().clone(), 255 | subtext: item_info.subtext.clone().unwrap_or(String::new()), 256 | vendor_type: item_info.vendor_type.clone(), 257 | value_ratio: value_ratio, 258 | gold: gold, 259 | silver: silver, 260 | copper: copper, 261 | mats: match item_info.mats { 262 | Some(ref mats_list) => mats_list.iter().map(|ref x| { 263 | let value = realm_prices.value_map.get(&x.id).unwrap() * x.quantity; 264 | let gold = value / (10_000); 265 | let silver = (value - gold * 10_000) / 100; 266 | let copper = value - gold * 10_000 - silver * 100; 267 | Material { 268 | name: item_id_map.get(&x.id).unwrap().name.clone(), 269 | gold: gold, 270 | silver: silver, 271 | copper: copper, 272 | } 273 | }).collect(), 274 | None => Vec::new(), 275 | }, 276 | } 277 | }).collect() 278 | }; 279 | let blood_price_rows = make_price_rows(&realm_prices.blood_item_values); 280 | let sargerite_price_rows = make_price_rows(&realm_prices.sargerite_item_values); 281 | context.add("realm_name", &realms.iter().find(|&realm_info| &realm_info.slug == realm).unwrap().name); 282 | context.add("blood_price_rows", &blood_price_rows); 283 | context.add("sargerite_price_rows", &sargerite_price_rows); 284 | // TODO: Change this to something more human readable. 285 | if realm_prices.last_update == 0 { 286 | context.add("update_age", &-1); 287 | } else { 288 | context.add("update_age", 289 | &((SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() - realm_prices.last_update / 1000) / 60)); 290 | } 291 | context.add("is_eu", &(locale == Region::EU)); 292 | Ok(Response::with((ContentType::html().0, status::Ok, tera.render("prices.html", context).unwrap()))) 293 | } else { 294 | return Ok(Response::with(status::NotFound)); 295 | } 296 | }, "realm-prices"); 297 | } 298 | let http_result = Iron::new(router).http(format!("localhost:{}", match locale { 299 | Region::US => 3000, 300 | Region::EU => 3001, 301 | }).as_str()); 302 | println!("Ready for web traffic."); 303 | 304 | // Now that the webserver is up, periodically fetch 305 | // new auction house data. 306 | let mut pool = Pool::new(NUM_AUCTION_DATA_THREADS); 307 | loop { 308 | let download_start = Instant::now(); 309 | let next_download_time = download_start + Duration::from_secs(RESULT_FETCH_PERIOD); 310 | println!("Starting download of auction data."); 311 | pool.scoped(|scope| { 312 | for realm_list in &connected_realms { 313 | // We have to move realm_list into the closure. 314 | // Clone other values. 315 | let client = client.clone(); 316 | let price_map = price_map.clone(); 317 | let item_id_map = item_id_map.clone(); 318 | scope.execute(move || { 319 | let lead_realm = realm_list.get(0).unwrap(); 320 | let update_time: u64; 321 | let auction_listings: Vec; 322 | println!("Downloading {}", lead_realm); 323 | { 324 | let current_realm_values = 325 | price_map.get(lead_realm).unwrap().read().unwrap(); 326 | match client.get_auction_listings(lead_realm, current_realm_values.last_update) { 327 | Some((ts, al)) => { 328 | update_time = ts; 329 | auction_listings = al; 330 | }, 331 | None => return, 332 | } 333 | } 334 | let (value_map, blood_item_values, sargerite_item_values) = calculate_auction_values(&auction_listings, &item_id_map); 335 | // Make thread safe versions of the new values. 336 | let value_map = Arc::new(value_map); 337 | let blood_item_values = Arc::new(blood_item_values); 338 | let sargerite_item_values = Arc::new(sargerite_item_values); 339 | //let auction_values = Arc::new(); 340 | for realm in realm_list { 341 | println!("Updating {}", realm); 342 | let mut current_realm_values = 343 | price_map.get(realm).unwrap().write().unwrap(); 344 | current_realm_values.value_map = Arc::clone(&value_map); 345 | current_realm_values.blood_item_values = Arc::clone(&blood_item_values); 346 | current_realm_values.sargerite_item_values = Arc::clone(&sargerite_item_values); 347 | current_realm_values.last_update = update_time; 348 | } 349 | }) 350 | } 351 | scope.join_all(); 352 | }); 353 | let download_end_time = Instant::now(); 354 | println!("Downloading all realms took {} seconds.", download_end_time.duration_since(download_start).as_secs()); 355 | if download_end_time < next_download_time { 356 | println!("Sleeping for {}", next_download_time.duration_since(download_end_time).as_secs()); 357 | sleep(next_download_time.duration_since(download_end_time)); 358 | } 359 | } 360 | } 361 | --------------------------------------------------------------------------------