odd-jobbed_rankings/src/main.rs

211 lines
6.2 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

use anyhow::{anyhow, bail, Result};
use futures::future::try_join_all;
use reqwest::Url;
use serde::Deserialize;
use std::{cmp::Ordering, io::Write, sync::Arc};
use tokio::sync::Mutex;
static PREAMBLE: &str = r##"# Unofficial “odd-jobbed rankings”
This “rankings” is almost certainly riddled with errors, inaccuracies, and
missing information, and should be treated as such. This is just for informal
use, so please dont take it too seriously. The levels of the characters listed
here are fetched directly from [the official MapleLegends web API
endpoint](https://maplelegends.com/api/) via [a Rust
script](https://codeberg.org/oddjobs/odd-jobbed_rankings/src/branch/master/src/main.rs).
To make the “rankings” actually maintainable, off-island characters who have
not yet achieved level 45, islanders who have not yet achieved level 40, and
campers who have not yet achieved level 10 are not represented here.
“IGN” stands for “in-game name”. The “name” entries are mostly for discerning
when two or more characters are controlled by the same player. The names, Ive
done on a best-effort basis, and some of them are just Discord™ identifiers
(which, it should be noted, can be changed at more or less any time, for any
reason).
Unknown or uncertain information is denoted by a question mark (“?”).
Legend:
- \*Not a member of <b>Suboptimal</b>.
- †Known to have leeched some non-negligible amount of EXP.
- ‡Known to have leeched a large amount of EXP.
- ⸸This job is not strictly odd, but is nevertheless eligible for membership in
Oddjobs. [See
here](https://deer.codeberg.page/diary/098/#perma-2nd-jobness-may-be-odd-on-its-own).
| IGN | name | level | job(s) | guild |
| :--------- | :----------- | ----: | :--------------------- | ------------- |
"##;
#[derive(Deserialize)]
struct CharsJson {
chars: Vec<Char>,
}
#[derive(Deserialize)]
struct Char {
// In chars.json:
ign: String,
name: Option<String>,
job: String,
nonclassical: Option<bool>,
leech: Option<String>,
// Fetched:
level: Option<u8>,
exp_percent: Option<f32>,
guild: Option<String>,
}
#[derive(Deserialize)]
struct LegendsApiResponse {
guild: String,
//name: String,
level: u8,
//job: String,
exp: String,
//quests: u16,
//cards: u16,
//donor: bool,
//fame: i32,
}
#[tokio::main]
async fn main() -> Result<()> {
// Intentionally blocking here.
let chars_json: CharsJson =
serde_json::from_reader(std::fs::File::open("chars.json")?)?;
let char_count = chars_json.chars.len();
let chars = Arc::new(Mutex::new(chars_json.chars));
// The actual async part.
let client = reqwest::Client::new();
for res in try_join_all((0..char_count).map(|i| {
tokio::spawn(fetch_info(client.clone(), Arc::clone(&chars), i))
}))
.await?
{
res?;
}
// More intentional blocking.
let mut output_file = std::fs::File::create("README.md.temp")?;
output_file.write_all(PREAMBLE.as_bytes())?;
let mut cs = chars.lock().await;
cs.sort_unstable_by(|c1, c2| match (c1.level, c2.level) {
(Some(l1), Some(l2)) => {
l2.cmp(&l1).then(match (c1.exp_percent, c2.exp_percent) {
(Some(ep1), Some(ep2)) => ep2.total_cmp(&ep1),
_ => Ordering::Equal,
})
}
_ => Ordering::Equal,
});
for c in cs.iter() {
let name_buf;
let name = if let Some(name) = &c.name {
name_buf = markdown_esc(name);
&name_buf
} else {
"?"
};
let job_symbol = if c.nonclassical == Some(true) {
""
} else {
""
};
let leech_symbol = match c.leech.as_ref().map(|s| s.as_ref()) {
Some("some") => "",
Some("lots") => "",
None | Some("none") => "",
_ => bail!("Unexpected value of \"leech\""),
};
let guild_buf;
let guild = if let Some(guild) = &c.guild {
guild_buf = markdown_esc(guild); // Excessively paranoid, I know.
&guild_buf
} else {
r"\[<i>none</i>\]"
};
writeln!(
&mut output_file,
"| {} | {name} | {leech_symbol}{} | {job_symbol}{} | {guild}{} |",
markdown_esc(&c.ign), // Excessively paranoid, I know.
c.level.ok_or_else(|| anyhow!(
"No level available for IGN {}",
c.ign,
))?,
markdown_esc(&c.job),
match c.guild.as_ref().map(|s| s.as_ref()) {
Some("Oddjobs") | Some("Flow") | Some("Victoria")
| Some("Ossyrians") | Some("lronman") => "",
_ => r"\*",
},
)?;
}
output_file.flush()?;
std::fs::rename("README.md.temp", "README.md")?;
Ok(())
}
async fn fetch_info(
client: reqwest::Client,
chars: Arc<Mutex<Vec<Char>>>,
char_ix: usize,
) -> Result<()> {
let url = {
let mut url = String::with_capacity(
"https://maplelegends.com/api/character?name=".len() + 12,
);
url.push_str("https://maplelegends.com/api/character?name=");
url.push_str(&chars.lock().await[char_ix].ign);
Url::parse(&url)?
};
let resp = client
.get(url.clone())
.send()
.await
.map_err(|e| e.with_url(url.clone()))?
.json::<LegendsApiResponse>()
.await
.map_err(|e| e.with_url(url))?;
let mut cs = chars.lock().await;
let c = &mut cs[char_ix];
c.level.replace(resp.level);
c.exp_percent = resp
.exp
.get(..resp.exp.len() - 1)
.and_then(|s| s.parse().ok());
c.guild = if resp.guild.is_empty() {
None
} else {
Some(resp.guild)
};
Ok(())
}
fn markdown_esc(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' | '.' | '_' | '*' | '+' | '-' | '=' | '(' | ')' | '['
| ']' | '{' | '}' | '<' | '>' | '#' | '~' | '^' | '\\' | '`'
| '|' | '!' => escaped.push('\\'),
_ => (),
}
escaped.push(c);
}
escaped
}