works in rust!

This commit is contained in:
Hamcha 2023-10-13 12:20:28 +02:00
commit 2a9658ef5c
Signed by: hamcha
GPG key ID: 1669C533B8CF6D89
7 changed files with 1788 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
*.json
*.txt

1473
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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);
}
}