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