works in rust!
This commit is contained in:
commit
2a9658ef5c
7 changed files with 1788 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
*.json
|
||||||
|
*.txt
|
1473
Cargo.lock
generated
Normal file
1473
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
[package]
|
||||||
|
name = "blah-rs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "0.11", features = ["stream"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
indicatif = "0.17"
|
||||||
|
anyhow = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
rand = "0.8"
|
34
src/cards.rs
Normal file
34
src/cards.rs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Card {
|
||||||
|
pub name: String,
|
||||||
|
pub types: Vec<String>,
|
||||||
|
pub uuid: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CardDB {
|
||||||
|
data: HashMap<String, Card>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CardDB {
|
||||||
|
pub fn load(data: Vec<u8>) -> Result<Self> {
|
||||||
|
Ok(serde_json::from_slice(&data)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_by_uuid(&self, uuid: &String) -> Option<&Card> {
|
||||||
|
self.data.get(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Card {
|
||||||
|
pub fn is_type(&self, card_type: &str) -> bool {
|
||||||
|
// Iter through types
|
||||||
|
self.types
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.to_lowercase() == card_type.to_lowercase())
|
||||||
|
}
|
||||||
|
}
|
59
src/files.rs
Normal file
59
src/files.rs
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use indicatif::{ProgressBar, ProgressStyle};
|
||||||
|
use reqwest::Client;
|
||||||
|
use std::{cmp::min, fs::File, io::Write};
|
||||||
|
|
||||||
|
pub async fn open_or_download(url: &str, path: &str) -> Result<Vec<u8>> {
|
||||||
|
// Download file if it doesn't exist
|
||||||
|
if !tokio::fs::try_exists(path).await? {
|
||||||
|
download_file(url, path).await?;
|
||||||
|
} else {
|
||||||
|
println!("{} found", path);
|
||||||
|
}
|
||||||
|
Ok(tokio::fs::read(path).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_file(url: &str, path: &str) -> Result<()> {
|
||||||
|
// Reqwest setup
|
||||||
|
let res = Client::new().get(url).send().await?;
|
||||||
|
let total_size = res.content_length().unwrap();
|
||||||
|
|
||||||
|
// Indicatif setup
|
||||||
|
let pb = ProgressBar::new(total_size);
|
||||||
|
pb.set_style(ProgressStyle::default_bar()
|
||||||
|
.template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")?
|
||||||
|
.progress_chars("#>-"));
|
||||||
|
pb.set_message(format!("Downloading {}", url));
|
||||||
|
|
||||||
|
let mut file = File::create(path)?;
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
let mut stream = res.bytes_stream();
|
||||||
|
|
||||||
|
while let Some(item) = stream.next().await {
|
||||||
|
let chunk = item?;
|
||||||
|
file.write_all(&chunk)?;
|
||||||
|
let new = min(downloaded + (chunk.len() as u64), total_size);
|
||||||
|
downloaded = new;
|
||||||
|
pb.set_position(new);
|
||||||
|
}
|
||||||
|
|
||||||
|
pb.finish_with_message(format!("Downloaded {} to {}", url, path));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_decklist(filepath: &str, decklist: Vec<String>) -> Result<()> {
|
||||||
|
let mut file =
|
||||||
|
File::create(filepath).or(Err(anyhow!("Failed to create file '{}'", filepath)))?;
|
||||||
|
|
||||||
|
let contents = decklist
|
||||||
|
.iter()
|
||||||
|
.map(|line| format!("1 {}\n", line))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
file.write_all(contents.as_bytes())?;
|
||||||
|
|
||||||
|
println!("Decklist saved to {}", filepath);
|
||||||
|
Ok(())
|
||||||
|
}
|
114
src/main.rs
Normal file
114
src/main.rs
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use rand::{seq::SliceRandom, Rng};
|
||||||
|
|
||||||
|
mod cards;
|
||||||
|
mod files;
|
||||||
|
mod price;
|
||||||
|
|
||||||
|
const IDENTIFIER_URL: &str = "https://mtgjson.com/api/v5/AllIdentifiers.json";
|
||||||
|
const PRICES_URL: &str = "https://mtgjson.com/api/v5/AllPricesToday.json";
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
/// Identifier file (AllIdentifiers.json), if not found it will be downloaded
|
||||||
|
#[arg(short = 'i', long, default_value = "AllIdentifiers.json")]
|
||||||
|
identifier_file: String,
|
||||||
|
|
||||||
|
/// Prices file (AllPricesToday.json), if not found it will be downloaded
|
||||||
|
#[arg(short = 'p', long, default_value = "AllPricesToday.json")]
|
||||||
|
prices_file: String,
|
||||||
|
|
||||||
|
/// Maximum card price allowed (in whole units of any currency)
|
||||||
|
#[arg(long = "price", default_value_t = 0.04f64)]
|
||||||
|
price_filter: f64,
|
||||||
|
|
||||||
|
/// Comma-separated list of card types to remove
|
||||||
|
#[arg(long, default_value = "token,emblem,sticker,card,hero,plane,scheme")]
|
||||||
|
remove_types: String,
|
||||||
|
|
||||||
|
/// How many cards to put in each "dollar store" pack
|
||||||
|
#[arg(short = 'c', long = "pack-size", default_value_t = 100)]
|
||||||
|
cards_per_draft: usize,
|
||||||
|
|
||||||
|
/// How many "dollar store" pack to generate
|
||||||
|
#[arg(short = 'n', long, default_value_t = 2)]
|
||||||
|
pack_count: usize,
|
||||||
|
|
||||||
|
/// Limit how many lands to put in each pack (0 to disable)
|
||||||
|
#[arg(short = 'l', long, default_value_t = 5)]
|
||||||
|
land_limit: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
let prices_file = files::open_or_download(PRICES_URL, &args.prices_file).await?;
|
||||||
|
let cards_file = files::open_or_download(IDENTIFIER_URL, &args.identifier_file).await?;
|
||||||
|
|
||||||
|
let prices = price::get_cards_in_budget(prices_file, args.price_filter)?;
|
||||||
|
let card_db = cards::CardDB::load(cards_file)?;
|
||||||
|
|
||||||
|
let remove_types: Vec<_> = args
|
||||||
|
.remove_types
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let cards: Vec<_> = prices
|
||||||
|
.iter()
|
||||||
|
// Map UUIDs to cards
|
||||||
|
.filter_map(|uuid| card_db.get_by_uuid(uuid))
|
||||||
|
// Remove types we don't care about
|
||||||
|
.filter(|card| !remove_types.iter().any(|t| card.is_type(t)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Print stats
|
||||||
|
println!("Found {} cards", cards.len());
|
||||||
|
|
||||||
|
// Generate packs of cards
|
||||||
|
for index in 0..args.pack_count {
|
||||||
|
let decklist = generate_pack(&cards, args.cards_per_draft, args.land_limit);
|
||||||
|
// Save decklist to file
|
||||||
|
files::save_decklist(&format!("decklist-{}.txt", index), decklist).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_pack(
|
||||||
|
card_list: &Vec<&cards::Card>,
|
||||||
|
cards_per_draft: usize,
|
||||||
|
land_limit: usize,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
|
// Generate a bunch of random cards
|
||||||
|
let mut picked: Vec<_> = (0..cards_per_draft)
|
||||||
|
.map(|_| card_list[rng.gen_range(0..card_list.len())])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Check for too many lands
|
||||||
|
if land_limit > 0 {
|
||||||
|
let non_lands: Vec<_> = card_list.iter().filter(|c| !c.is_type("land")).collect();
|
||||||
|
let mut num_lands = picked.iter().filter(|c| c.is_type("land")).count();
|
||||||
|
|
||||||
|
picked = picked
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_type("land") && num_lands > land_limit {
|
||||||
|
num_lands -= 1;
|
||||||
|
non_lands.choose(&mut rng).unwrap()
|
||||||
|
} else {
|
||||||
|
c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the picked card names
|
||||||
|
picked.iter().map(|c| c.name.clone()).collect()
|
||||||
|
}
|
90
src/price.rs
Normal file
90
src/price.rs
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PricePoints {
|
||||||
|
normal: HashMap<String, f64>,
|
||||||
|
foil: HashMap<String, f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PriceList {
|
||||||
|
retail: Option<PricePoints>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PriceRecord {
|
||||||
|
paper: Option<HashMap<String, PriceList>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PriceDB {
|
||||||
|
data: HashMap<String, PriceRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_cards_in_budget(prices_file: Vec<u8>, price_filter: f64) -> Result<Vec<String>> {
|
||||||
|
// Read prices file
|
||||||
|
let price_db: PriceDB = serde_json::from_slice(&prices_file)?;
|
||||||
|
|
||||||
|
// Filter cards we care about
|
||||||
|
Ok(price_db
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, price_list)| match &price_list.paper {
|
||||||
|
Some(prices) => get_lowest_price(prices) < price_filter,
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
.map(|(uuid, _)| uuid.clone())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_lowest_price(sellers: &HashMap<String, PriceList>) -> f64 {
|
||||||
|
// Iterate over each seller and their price list
|
||||||
|
sellers
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(_, price_list)| {
|
||||||
|
// Extract the retail price list, if it exists
|
||||||
|
price_list.retail.as_ref().map(|retail| {
|
||||||
|
// Combine normal and foil price lists
|
||||||
|
retail
|
||||||
|
.normal
|
||||||
|
.iter()
|
||||||
|
.chain(retail.foil.iter())
|
||||||
|
// Extract the prices from the price lists
|
||||||
|
.map(|(_, &price)| price)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Flatten the nested iterators into a single iterator
|
||||||
|
.flatten()
|
||||||
|
// Find the minimum price using the fold function
|
||||||
|
.fold(f64::MAX, |min_price, price| min_price.min(price))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_get_lowest_price() {
|
||||||
|
let mut sellers: HashMap<String, PriceList> = HashMap::new();
|
||||||
|
let price_list1 = PriceList {
|
||||||
|
retail: Some(PricePoints {
|
||||||
|
normal: vec![("2022-01-22".to_string(), 10.0)].into_iter().collect(),
|
||||||
|
foil: vec![("2022-01-22".to_string(), 20.0)].into_iter().collect(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
sellers.insert("seller1".to_string(), price_list1);
|
||||||
|
|
||||||
|
let price_list2: PriceList = PriceList {
|
||||||
|
retail: Some(PricePoints {
|
||||||
|
normal: vec![("2022-01-22".to_string(), 15.0)].into_iter().collect(),
|
||||||
|
foil: vec![("2022-01-22".to_string(), 25.0)].into_iter().collect(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
sellers.insert("seller2".to_string(), price_list2);
|
||||||
|
|
||||||
|
let lowest_price = get_lowest_price(&sellers);
|
||||||
|
assert_eq!(lowest_price, 10.0);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue