├── assets ├── icon.ico └── classes │ ├── bard.webp │ ├── druid.webp │ ├── mage.webp │ ├── scout.webp │ ├── assassin.webp │ ├── berserk.webp │ ├── paladin.webp │ ├── warrior.webp │ ├── battle_mage.webp │ ├── demon_hunter.webp │ └── necromancer.webp ├── .gitignore ├── .rustfmt.toml ├── .github ├── ISSUE_TEMPLATE │ ├── feature-request.md │ └── bug_report.md └── workflows │ ├── rust.yml │ └── release.yml ├── release_mac.sh ├── LICENSE ├── Cargo.toml ├── src ├── ui │ ├── options.rs │ ├── underworld.rs │ ├── scrapbook.rs │ └── mod.rs ├── server.rs ├── player.rs ├── backup.rs ├── config.rs ├── crawler.rs ├── login.rs └── main.rs └── README.md /assets/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/icon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.zhof 3 | helper.toml 4 | .DS_Store 5 | *.log 6 | *.zip 7 | *.sha256sum 8 | /dist 9 | -------------------------------------------------------------------------------- /assets/classes/bard.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/bard.webp -------------------------------------------------------------------------------- /assets/classes/druid.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/druid.webp -------------------------------------------------------------------------------- /assets/classes/mage.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/mage.webp -------------------------------------------------------------------------------- /assets/classes/scout.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/scout.webp -------------------------------------------------------------------------------- /assets/classes/assassin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/assassin.webp -------------------------------------------------------------------------------- /assets/classes/berserk.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/berserk.webp -------------------------------------------------------------------------------- /assets/classes/paladin.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/paladin.webp -------------------------------------------------------------------------------- /assets/classes/warrior.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/warrior.webp -------------------------------------------------------------------------------- /assets/classes/battle_mage.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/battle_mage.webp -------------------------------------------------------------------------------- /assets/classes/demon_hunter.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/demon_hunter.webp -------------------------------------------------------------------------------- /assets/classes/necromancer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-marenga/sf-scrapbook-helper/HEAD/assets/classes/necromancer.webp -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width=80 2 | wrap_comments=true 3 | format_strings=true 4 | imports_granularity="Crate" 5 | group_imports = "StdExternalCrate" 6 | use_field_init_shorthand = true 7 | normalize_comments = true 8 | empty_item_single_line = false 9 | short_array_element_width_threshold=80 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature Request] *Your Feature Name*" 5 | labels: enhancement 6 | assignees: the-marenga 7 | 8 | --- 9 | 10 | **Describe the feature you'd like to see** 11 | ... 12 | 13 | **Explain how it would make your experience better** 14 | ... 15 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v5 18 | - uses: actions-rust-lang/setup-rust-toolchain@v1 19 | - name: Run Cargo Check 20 | run: cargo check 21 | -------------------------------------------------------------------------------- /release_mac.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This is a helper script to build the two mac releases & generate their 4 | # checksums. This is only for the github release, you should use `cargo run` 5 | # as usual to run this shoftware 6 | 7 | version=$(ggrep -oP '(?<=^version = ")[^"]*' Cargo.toml) 8 | targets=("x86_64-apple-darwin" "aarch64-apple-darwin") 9 | 10 | mkdir -p dist 11 | for target in "${targets[@]}"; do 12 | cargo b -r -q --target $target; 13 | cp target/${target}/release/sf-scrapbook-helper sf-scrapbook-helper 14 | outfile="sf-scrapbook-helper_v${version}_${target}.zip" 15 | zip "${outfile}" sf-scrapbook-helper 16 | rm sf-scrapbook-helper 17 | sha256sum "${outfile}" > "dist/${outfile}.sha256sum" 18 | mv "${outfile}" "dist/${outfile}" 19 | done 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem in the program 4 | title: "[Bug] *A short name for your bug*" 5 | labels: bug 6 | assignees: the-marenga 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Machine (please complete the following information):** 27 | - OS: [e.g. Windows] 28 | - Version [e.g. 0.2.0] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Marenga 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 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sf-scrapbook-helper" 3 | version = "0.2.7" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | ahash = "0.8" 8 | async-compression = { version = "0.4", features = ["zlib"] } 9 | chrono = "0.4" 10 | clap = { version = "4.5.45", features = ["derive"] } 11 | clap-num = "1.2.0" 12 | fastrand = "2.3" 13 | iced = { version = "0.12", default-features = false, features = [ 14 | "tokio", 15 | "lazy", 16 | "image", 17 | "advanced", 18 | ] } 19 | iced_aw = { version = "0.9", default-features = false, features = [ 20 | "number_input", 21 | "icons", 22 | "drop_down", 23 | ] } 24 | image = { version = "0.25", default-features = false, features = [ 25 | "ico", 26 | "webp", 27 | ] } 28 | indicatif = "0.17.11" 29 | log = "0.4.27" 30 | log4rs = { version = "1.3.0" } 31 | nohash-hasher = "0.2" 32 | num-format = "0.4.4" 33 | open = "5.3" 34 | reqwest = { version = "0.12", features = ["gzip", "deflate", "brotli"] } 35 | semver = "1.0.26" 36 | serde = "1.0" 37 | serde_json = "1.0" 38 | sf-api = { git = "https://github.com/the-marenga/sf-api.git", version = "0.3.0" } 39 | titlecase = "3.6" 40 | tokio = { version = "1.47", default-features = false, features = ["fs"] } 41 | toml = "0.8" 42 | 43 | [profile.release] 44 | strip = true 45 | lto = true 46 | opt-level = 3 47 | codegen-units = 1 48 | panic = "abort" 49 | 50 | [profile.dev] 51 | opt-level = 1 52 | 53 | # Async decompression and iced can become very slow on low opt-levels. 54 | # This adds a bit of compile overhead, but that time gets easily recouped 55 | # from the async decomp, etc speedups 56 | [profile.dev.package."*"] 57 | opt-level = 2 58 | 59 | [build-dependencies] 60 | winres = "0.1" 61 | -------------------------------------------------------------------------------- /src/ui/options.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Alignment, Element, Length, 3 | widget::{checkbox, column, text}, 4 | }; 5 | 6 | use crate::{ 7 | config::Config, message::Message, player::AccountInfo, server::ServerInfo, 8 | }; 9 | 10 | pub fn view_options<'a>( 11 | player: &'a AccountInfo, 12 | og_server: &'a ServerInfo, 13 | config: &'a Config, 14 | ) -> Element<'a, Message> { 15 | let config = config.get_char_conf(&player.name, og_server.ident.id); 16 | 17 | let Some(config) = config else { 18 | return text( 19 | "Use 'Remember me' during login to store player configurations", 20 | ) 21 | .size(20) 22 | .into(); 23 | }; 24 | 25 | let mut all = column!().spacing(20).width(Length::Fixed(300.0)); 26 | 27 | all = all.push( 28 | checkbox("Automatically login on startup", config.login).on_toggle( 29 | |nv| Message::ConfigSetAutoLogin { 30 | name: player.name.clone(), 31 | server: og_server.ident.id, 32 | nv, 33 | }, 34 | ), 35 | ); 36 | 37 | all = all.push( 38 | checkbox("Enable auto-battle on login", config.auto_battle).on_toggle( 39 | |nv| Message::ConfigSetAutoBattle { 40 | name: player.name.clone(), 41 | server: og_server.ident.id, 42 | nv, 43 | }, 44 | ), 45 | ); 46 | 47 | all = all.push( 48 | checkbox("Enable auto-lure on login", config.auto_lure).on_toggle( 49 | |nv| Message::ConfigSetAutoLure { 50 | name: player.name.clone(), 51 | server: og_server.ident.id, 52 | nv, 53 | }, 54 | ), 55 | ); 56 | 57 | column!(all) 58 | .padding(20) 59 | .height(Length::Fill) 60 | .width(Length::Fill) 61 | .align_items(Alignment::Center) 62 | .into() 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > S&F added rate-limiting, so the ability to manually crawl an entire server is limited for the foreseeable future. 3 | 4 | # Logo S&F Scrapbook Helper 5 | ![Build Status](https://img.shields.io/github/actions/workflow/status/the-marenga/sf-scrapbook-helper/rust.yml?branch=main) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![Github All Releases](https://img.shields.io/github/downloads/the-marenga/sf-scrapbook-helper/total?logo=github)](https://github.com/the-marenga/sf-scrapbook-helper/releases/latest) [](https://ko-fi.com/J3J0ULD4J) 6 | 7 | Find the players with the most items, that you have not yet collected. You can either attack the best players manually (which might cost a mushroom), or click the automate button to battle the best character as soon, as it is free. 8 | 9 | Supports multiple normal, SF, Google and Steam accounts simultaneously. 10 | 11 | The HoF will initially be fetched from a recent snapshot of the server. If you want a more recent version, you can crawl the server data yourself via the buttons on the left side. If you want to pause the progress, you can store this crawling progress to disk and restore it at a later date. 12 | 13 | 14 | 15 | ## Download 16 | 17 | You can always find the newest version [here](https://github.com/the-marenga/sf-scrapbook-helper/releases/latest) 18 | 19 | ## Privacy Notice 20 | If you want to have your account data (username+equipment) removed from the online HoF data set, or you represent playagames and you have an issue with the HoF data being shared at all, feel free to open an issue, or contact me via: 21 | 22 | `remove_hof@marenga.dev` 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | jobs: 6 | release: 7 | name: release ${{ matrix.target }} 8 | runs-on: ${{ matrix.os }} 9 | permissions: 10 | contents: write 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - target: x86_64-pc-windows-gnu 16 | archive: zip 17 | os: windows-latest 18 | exe_name: sf-scrapbook-helper.exe 19 | - target: x86_64-unknown-linux-gnu 20 | archive: tar.gz 21 | os: ubuntu-latest 22 | exe_name: sf-scrapbook-helper 23 | - target: x86_64-apple-darwin 24 | archive: zip 25 | os: macos-latest 26 | exe_name: sf-scrapbook-helper 27 | - target: aarch64-apple-darwin 28 | archive: zip 29 | os: macos-latest 30 | exe_name: sf-scrapbook-helper 31 | steps: 32 | - uses: actions/checkout@v5 33 | - uses: actions-rust-lang/setup-rust-toolchain@v1 34 | with: 35 | target: ${{ matrix.target }} 36 | - name: Build Executable 37 | run: cargo b -r --target ${{ matrix.target }} 38 | - name: Create ZIP archive 39 | shell: bash 40 | run: | 41 | mkdir -p release 42 | cp "./target/${{ matrix.target }}/release/${{ matrix.exe_name }}" "./release/${{ matrix.exe_name }}" 43 | if [[ "${{ matrix.os }}" != "windows-latest" ]]; then 44 | cd release 45 | if [[ "${{ matrix.archive }}" == "tar.gz" ]]; then 46 | tar -czf "sf-scrapbook-helper_${{ github.event.release.tag_name }}_${{ matrix.target }}.${{ matrix.archive }}" "${{ matrix.exe_name }}" 47 | else 48 | zip -j "sf-scrapbook-helper_${{ github.event.release.tag_name }}_${{ matrix.target }}.${{ matrix.archive }}" "${{ matrix.exe_name }}" 49 | fi 50 | cd .. 51 | rm -f "./release/${{ matrix.exe_name }}" 52 | else 53 | cd release 54 | pwsh -Command "Compress-Archive -Path '${{ matrix.exe_name }}' -DestinationPath 'sf-scrapbook-helper_${{ github.event.release.tag_name }}_${{ matrix.target }}.${{ matrix.archive }}'" 55 | cd .. 56 | pwsh -Command "Remove-Item './release/${{ matrix.exe_name }}'" 57 | fi 58 | - name: Upload build artifact to release 59 | uses: ncipollo/release-action@v1 60 | with: 61 | artifacts: "./release/sf-scrapbook-helper_${{ github.event.release.tag_name }}_${{ matrix.target }}.${{ matrix.archive }}" 62 | allowUpdates: true 63 | omitBody: true 64 | tag: ${{ github.event.release.tag_name }} 65 | -------------------------------------------------------------------------------- /src/server.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{BTreeMap, HashMap, HashSet}, 3 | hash::Hasher, 4 | sync::{Arc, Mutex}, 5 | }; 6 | 7 | use chrono::{DateTime, Local}; 8 | use nohash_hasher::{IntMap, IntSet}; 9 | use sf_api::{ 10 | gamestate::unlockables::EquipmentIdent, session::ServerConnection, 11 | }; 12 | 13 | use crate::{ 14 | AccountID, AccountIdent, CharacterInfo, QueID, ServerID, 15 | crawler::{CrawlAction, CrawlerState, WorkerQue}, 16 | player::AccountInfo, 17 | }; 18 | 19 | #[derive(Debug, Clone)] 20 | pub enum CrawlingStatus { 21 | Waiting, 22 | Restoring, 23 | CrawlingFailed(String), 24 | Crawling { 25 | que_id: QueID, 26 | threads: usize, 27 | que: Arc>, 28 | player_info: IntMap, 29 | equipment: HashMap< 30 | EquipmentIdent, 31 | HashSet, 32 | ahash::RandomState, 33 | >, 34 | naked: BTreeMap>, 35 | last_update: DateTime, 36 | crawling_session: Option>, 37 | recent_failures: Vec, 38 | }, 39 | } 40 | 41 | pub struct ServerInfo { 42 | pub ident: ServerIdent, 43 | pub accounts: HashMap, 44 | pub crawling: CrawlingStatus, 45 | pub connection: ServerConnection, 46 | pub headless_progress: Option, 47 | } 48 | 49 | #[derive(Debug, Clone, PartialEq, Eq, Hash)] 50 | pub struct ServerIdent { 51 | pub id: ServerID, 52 | pub url: String, 53 | pub ident: String, 54 | } 55 | 56 | impl ServerIdent { 57 | pub fn new(url: &str) -> Self { 58 | let url = url.trim_start_matches("https:"); 59 | let url: String = url 60 | .chars() 61 | .map(|a| a.to_ascii_lowercase()) 62 | .filter(|a| *a != '/') 63 | .collect(); 64 | let ident: String = 65 | url.chars().filter(|a| a.is_alphanumeric()).collect(); 66 | let mut hasher = ahash::AHasher::default(); 67 | hasher.write(ident.as_bytes()); 68 | let id = hasher.finish(); 69 | ServerIdent { 70 | id: ServerID(id), 71 | url, 72 | ident, 73 | } 74 | } 75 | } 76 | 77 | #[derive(Default)] 78 | pub struct Servers(pub HashMap); 79 | 80 | impl Servers { 81 | pub fn get_or_insert_default( 82 | &mut self, 83 | server_ident: ServerIdent, 84 | connection: ServerConnection, 85 | pb: Option, 86 | ) -> &mut ServerInfo { 87 | self.0.entry(server_ident.id).or_insert_with(|| ServerInfo { 88 | ident: server_ident.clone(), 89 | accounts: Default::default(), 90 | crawling: CrawlingStatus::Waiting, 91 | connection, 92 | headless_progress: pb, 93 | }) 94 | } 95 | 96 | pub fn get_ident( 97 | &self, 98 | ident: &AccountIdent, 99 | ) -> Option<(&ServerInfo, &AccountInfo)> { 100 | let server = self.0.get(&ident.server_id)?; 101 | let account = server.accounts.get(&ident.account)?; 102 | Some((server, account)) 103 | } 104 | 105 | pub fn get(&self, id: &ServerID) -> Option<&ServerInfo> { 106 | self.0.get(id) 107 | } 108 | 109 | pub fn get_mut(&mut self, id: &ServerID) -> Option<&mut ServerInfo> { 110 | self.0.get_mut(id) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/ui/underworld.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Alignment, Element, Length, 3 | alignment::Horizontal, 4 | theme, 5 | widget::{ 6 | Image, button, checkbox, column, horizontal_space, row, scrollable, 7 | text, vertical_space, 8 | }, 9 | }; 10 | use iced_aw::number_input; 11 | 12 | use super::view_crawling; 13 | use crate::{ 14 | ClassImages, 15 | config::Config, 16 | message::Message, 17 | player::{AccountInfo, AccountStatus}, 18 | server::ServerInfo, 19 | }; 20 | 21 | pub fn view_underworld<'a>( 22 | server: &'a ServerInfo, 23 | player: &'a AccountInfo, 24 | config: &'a Config, 25 | images: &'a ClassImages, 26 | ) -> Element<'a, Message> { 27 | let lock = player.status.lock().unwrap(); 28 | let _gs = match &*lock { 29 | AccountStatus::LoggingIn => return text("Loggin in").size(20).into(), 30 | AccountStatus::Idle(_, gs) => gs, 31 | AccountStatus::Busy(gs, _) => gs, 32 | AccountStatus::FatalError(err) => { 33 | return text(format!("Error: {err}")).size(20).into(); 34 | } 35 | AccountStatus::LoggingInAgain => { 36 | return text("Logging in player again".to_string()).size(20).into(); 37 | } 38 | }; 39 | 40 | let Some(info) = &player.underworld_info else { 41 | return text("Underworld not unlocked yet".to_string()) 42 | .size(20) 43 | .into(); 44 | }; 45 | 46 | let mut left_col = column!().align_items(Alignment::Center).spacing(10); 47 | left_col = left_col.push(row!( 48 | text("Lured Today:").width(Length::FillPortion(1)), 49 | text(format!("{}/5", info.underworld.lured_today)) 50 | .width(Length::FillPortion(1)) 51 | .horizontal_alignment(Horizontal::Right), 52 | )); 53 | 54 | let souls = info.underworld.souls_current; 55 | let souls_limit = info.underworld.souls_limit; 56 | 57 | left_col = left_col.push(row!( 58 | text("Souls Filled:").width(Length::FillPortion(1)), 59 | text(format!( 60 | "{:.0}%", 61 | (souls as f32 / (souls_limit.max(1)) as f32) * 100.0 62 | )) 63 | .width(Length::FillPortion(1)) 64 | .horizontal_alignment(Horizontal::Right), 65 | )); 66 | 67 | let avg_lvl = info 68 | .underworld 69 | .units 70 | .as_array() 71 | .iter() 72 | .map(|a| a.level as u64) 73 | .sum::() as f32 74 | / 3.0; 75 | left_col = left_col.push(row!( 76 | text("Avg Unit Level:").width(Length::FillPortion(1)), 77 | text(format!("{:.0}", avg_lvl)) 78 | .width(Length::FillPortion(1)) 79 | .horizontal_alignment(Horizontal::Right), 80 | )); 81 | let aid = player.ident; 82 | let max_lvl = number_input(info.max_level, 9999, move |nv| { 83 | Message::PlayerSetMaxUndergroundLvl { 84 | ident: aid, 85 | lvl: nv, 86 | } 87 | }); 88 | let max_lvl = row!(text("Max Level:"), horizontal_space(), max_lvl) 89 | .align_items(Alignment::Center); 90 | left_col = left_col.push(max_lvl); 91 | left_col = left_col.push( 92 | checkbox("Auto Lure", info.auto_lure) 93 | .on_toggle(|a| Message::AutoLure { 94 | ident: player.ident, 95 | state: a, 96 | }) 97 | .size(20), 98 | ); 99 | left_col = left_col.push(button("Copy Targets").on_press( 100 | Message::CopyBestLures { 101 | ident: player.ident, 102 | }, 103 | )); 104 | 105 | if !info.attack_log.is_empty() { 106 | let mut log = column!().padding(5).spacing(5); 107 | 108 | for (time, target, won) in info.attack_log.iter().rev() { 109 | let time = text(format!("{}", time.time().format("%H:%M"))); 110 | let target = text(target); 111 | let row = button(row!(target, horizontal_space(), time)).style( 112 | match won { 113 | true => theme::Button::Positive, 114 | false => theme::Button::Destructive, 115 | }, 116 | ); 117 | log = log.push(row.padding(5)); 118 | } 119 | 120 | left_col = left_col.push(scrollable(log).height(Length::Fixed(200.0))); 121 | } 122 | 123 | left_col = left_col.push(vertical_space()); 124 | left_col = left_col.push(view_crawling(server, config)); 125 | 126 | let mut name_bar = column!(); 127 | name_bar = name_bar.push(row!( 128 | text("") 129 | .width(Length::FillPortion(1)) 130 | .horizontal_alignment(Horizontal::Center), 131 | text("Level") 132 | .width(Length::FillPortion(1)) 133 | .horizontal_alignment(Horizontal::Center), 134 | text("Items") 135 | .width(Length::FillPortion(1)) 136 | .horizontal_alignment(Horizontal::Center), 137 | text("Name") 138 | .width(Length::FillPortion(3)) 139 | .horizontal_alignment(Horizontal::Left), 140 | )); 141 | let name_bar = scrollable(name_bar); 142 | 143 | let mut target_list = column!().spacing(10); 144 | for v in &info.best { 145 | let mut target_ident = row!() 146 | .align_items(Alignment::Start) 147 | .spacing(5) 148 | .width(Length::FillPortion(3)); 149 | 150 | if let Some(class) = v.class 151 | && config.show_class_icons 152 | { 153 | let img = Image::new(images.get_handle(class)) 154 | .width(Length::FillPortion(1)) 155 | .content_fit(iced::ContentFit::ScaleDown); 156 | target_ident = target_ident.push(img); 157 | } 158 | target_ident = target_ident.push( 159 | text(&v.name) 160 | .width(Length::FillPortion(15)) 161 | .horizontal_alignment(Horizontal::Left), 162 | ); 163 | 164 | target_list = target_list.push(row!( 165 | column!(button("Lure").on_press_maybe( 166 | if info.underworld.lured_today >= 5 { 167 | None 168 | } else { 169 | Some(Message::PlayerLure { 170 | ident: player.ident, 171 | target: LureTarget { 172 | uid: v.uid, 173 | name: v.name.clone(), 174 | }, 175 | }) 176 | } 177 | )) 178 | .align_items(Alignment::Center) 179 | .width(Length::FillPortion(1)), 180 | text(v.level) 181 | .width(Length::FillPortion(1)) 182 | .horizontal_alignment(Horizontal::Center), 183 | text(v.equipment.len()) 184 | .width(Length::FillPortion(1)) 185 | .horizontal_alignment(Horizontal::Center), 186 | target_ident 187 | )); 188 | } 189 | let target_list = scrollable(target_list); 190 | let right_col = column!(name_bar, target_list).spacing(10); 191 | 192 | row!( 193 | left_col.width(Length::Fixed(200.0)), 194 | right_col.width(Length::Fill) 195 | ) 196 | .padding(15) 197 | .height(Length::Fill) 198 | .align_items(Alignment::Start) 199 | .into() 200 | } 201 | 202 | #[derive(Debug, Clone)] 203 | pub struct LureTarget { 204 | pub uid: u32, 205 | pub name: String, 206 | } 207 | -------------------------------------------------------------------------------- /src/player.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::{Arc, Mutex}, 3 | time::Duration, 4 | }; 5 | 6 | use chrono::{DateTime, Local}; 7 | use log::trace; 8 | use nohash_hasher::IntMap; 9 | use sf_api::{ 10 | gamestate::{GameState, underworld::Underworld, unlockables::ScrapBook}, 11 | session::Session, 12 | }; 13 | use tokio::time::sleep; 14 | 15 | use crate::{ 16 | AccountIdent, AttackTarget, CharacterInfo, config::CharacterConfig, 17 | login::PlayerAuth, message::Message, 18 | }; 19 | 20 | pub struct AccountInfo { 21 | pub name: String, 22 | pub ident: AccountIdent, 23 | pub auth: PlayerAuth, 24 | pub last_updated: DateTime, 25 | pub status: Arc>, 26 | pub scrapbook_info: Option, 27 | pub underworld_info: Option, 28 | } 29 | 30 | pub struct UnderworldInfo { 31 | pub underworld: Underworld, 32 | pub best: Vec, 33 | pub max_level: u16, 34 | pub attack_log: Vec<(DateTime, String, bool)>, 35 | pub auto_lure: bool, 36 | } 37 | 38 | impl UnderworldInfo { 39 | pub fn new( 40 | gs: &GameState, 41 | config: Option<&CharacterConfig>, 42 | ) -> Option { 43 | let underworld = gs.underworld.as_ref()?.clone(); 44 | let avg_lvl = underworld 45 | .units 46 | .as_array() 47 | .iter() 48 | .map(|a| a.level as u64) 49 | .sum::() as f32 50 | / 3.0; 51 | Some(Self { 52 | underworld, 53 | best: Default::default(), 54 | max_level: avg_lvl as u16 + 20, 55 | attack_log: Vec::new(), 56 | auto_lure: config.map(|a| a.auto_lure).unwrap_or(false), 57 | }) 58 | } 59 | } 60 | 61 | pub struct ScrapbookInfo { 62 | pub scrapbook: ScrapBook, 63 | pub best: Vec, 64 | pub max_level: u16, 65 | pub max_attributes: u32, 66 | pub blacklist: IntMap, 67 | pub attack_log: Vec<(DateTime, AttackTarget, bool)>, 68 | pub auto_battle: bool, 69 | } 70 | 71 | impl ScrapbookInfo { 72 | pub fn new( 73 | gs: &GameState, 74 | config: Option<&CharacterConfig>, 75 | ) -> Option { 76 | let max_attributes = { 77 | let base = gs.character.attribute_basis.as_array(); 78 | let bonus = gs.character.attribute_additions.as_array(); 79 | let total = base.iter().chain(bonus).sum::(); 80 | let expected_battle_luck = 1.2f32; 81 | (total as f32 * expected_battle_luck) as u32 82 | }; 83 | 84 | Some(Self { 85 | scrapbook: gs.character.scrapbook.as_ref()?.clone(), 86 | best: Default::default(), 87 | max_level: gs.character.level, 88 | max_attributes, 89 | blacklist: Default::default(), 90 | attack_log: Default::default(), 91 | auto_battle: config.map(|a| a.auto_battle).unwrap_or(false), 92 | }) 93 | } 94 | } 95 | 96 | impl AccountInfo { 97 | pub fn new( 98 | name: &str, 99 | auth: PlayerAuth, 100 | ident: AccountIdent, 101 | ) -> AccountInfo { 102 | AccountInfo { 103 | name: name.to_string(), 104 | auth, 105 | scrapbook_info: None, 106 | underworld_info: None, 107 | last_updated: Local::now(), 108 | status: Arc::new(Mutex::new(AccountStatus::LoggingIn)), 109 | ident, 110 | } 111 | } 112 | } 113 | 114 | pub enum AccountStatus { 115 | LoggingIn, 116 | Idle(Box, Box), 117 | Busy(Box, Box), 118 | FatalError(String), 119 | LoggingInAgain, 120 | } 121 | 122 | impl AccountStatus { 123 | pub fn take_session>>( 124 | &mut self, 125 | reason: T, 126 | ) -> Option> { 127 | let mut res = None; 128 | *self = match std::mem::replace(self, AccountStatus::LoggingInAgain) { 129 | AccountStatus::Idle(a, b) => { 130 | res = Some(a); 131 | AccountStatus::Busy(b, reason.into()) 132 | } 133 | x => x, 134 | }; 135 | res 136 | } 137 | 138 | pub fn put_session(&mut self, session: Box) { 139 | *self = match std::mem::replace(self, AccountStatus::LoggingInAgain) { 140 | AccountStatus::Busy(a, _) => AccountStatus::Idle(session, a), 141 | x => x, 142 | }; 143 | } 144 | } 145 | 146 | pub struct AutoAttackChecker { 147 | pub player_status: Arc>, 148 | pub ident: AccountIdent, 149 | } 150 | 151 | impl AutoAttackChecker { 152 | pub async fn check(&self) -> Message { 153 | let next_fight: Option> = { 154 | match &*self.player_status.lock().unwrap() { 155 | AccountStatus::Idle(_, session) => { 156 | session.arena.next_free_fight 157 | } 158 | _ => None, 159 | } 160 | }; 161 | if let Some(next) = next_fight { 162 | let remaining = next - Local::now(); 163 | if let Ok(remaining) = remaining.to_std() { 164 | tokio::time::sleep(remaining).await; 165 | } 166 | }; 167 | tokio::time::sleep(Duration::from_millis(fastrand::u64(1000..=3000))) 168 | .await; 169 | 170 | Message::AutoBattlePossible { ident: self.ident } 171 | } 172 | } 173 | 174 | pub struct AutoLureChecker { 175 | pub player_status: Arc>, 176 | pub ident: AccountIdent, 177 | } 178 | 179 | impl AutoLureChecker { 180 | pub async fn check(&self) -> Message { 181 | let lured = { 182 | match &*self.player_status.lock().unwrap() { 183 | AccountStatus::Idle(_, session) => { 184 | session.underworld.as_ref().map(|a| a.lured_today) 185 | } 186 | _ => None, 187 | } 188 | }; 189 | let Some(0..=4) = lured else { 190 | // Either no underworld, or already lured the max 191 | tokio::time::sleep(Duration::from_millis(fastrand::u64( 192 | 5000..=10_000, 193 | ))) 194 | .await; 195 | return Message::AutoLureIdle; 196 | }; 197 | 198 | tokio::time::sleep(Duration::from_millis(fastrand::u64(3000..=5000))) 199 | .await; 200 | 201 | Message::AutoLurePossible { ident: self.ident } 202 | } 203 | } 204 | 205 | pub struct AutoPoll { 206 | pub player_status: Arc>, 207 | pub ident: AccountIdent, 208 | } 209 | 210 | impl AutoPoll { 211 | pub async fn check(&self) -> Message { 212 | sleep(Duration::from_millis(fastrand::u64(5000..=10000))).await; 213 | let mut session = { 214 | let mut lock = self.player_status.lock().unwrap(); 215 | let res = lock.take_session("Auto Poll"); 216 | match res { 217 | Some(res) => res, 218 | None => return Message::PlayerNotPolled { ident: self.ident }, 219 | } 220 | }; 221 | 222 | trace!("Sending poll {:?}", self.ident); 223 | 224 | let Ok(resp) = session 225 | .send_command(&sf_api::command::Command::Update) 226 | .await 227 | else { 228 | return Message::PlayerCommandFailed { 229 | ident: self.ident, 230 | session, 231 | attempt: 0, 232 | }; 233 | }; 234 | let mut lock = self.player_status.lock().unwrap(); 235 | let gs = match &mut *lock { 236 | AccountStatus::Busy(gs, _) => gs, 237 | _ => { 238 | lock.put_session(session); 239 | return Message::PlayerNotPolled { ident: self.ident }; 240 | } 241 | }; 242 | if gs.update(resp).is_err() { 243 | return Message::PlayerCommandFailed { 244 | ident: self.ident, 245 | session, 246 | attempt: 0, 247 | }; 248 | } 249 | lock.put_session(session); 250 | Message::PlayerPolled { ident: self.ident } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/backup.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::{BTreeMap, HashMap, HashSet}, 3 | sync::{Arc, Mutex}, 4 | }; 5 | 6 | use async_compression::tokio::write::ZlibEncoder; 7 | use chrono::{DateTime, Local, Utc}; 8 | use log::{debug, warn}; 9 | use nohash_hasher::{IntMap, IntSet}; 10 | use serde::{Deserialize, Serialize}; 11 | use sf_api::gamestate::unlockables::EquipmentIdent; 12 | use tokio::{ 13 | io::{AsyncWriteExt, BufReader}, 14 | task::yield_now, 15 | }; 16 | 17 | use crate::{ 18 | CharacterInfo, CrawlingOrder, CrawlingStatus, QueID, WorkerQue, 19 | handle_new_char_info, 20 | }; 21 | 22 | pub async fn restore_backup( 23 | backup: Option>, 24 | total_pages: usize, 25 | ) -> RestoreData { 26 | if backup.is_none() { 27 | debug!("Reset crawling progress"); 28 | } else { 29 | debug!("Restoring local backup"); 30 | } 31 | 32 | let new_info = match backup { 33 | Some(backup) => backup, 34 | None => Box::new(ZHofBackup { 35 | todo_pages: (0..total_pages).collect(), 36 | invalid_pages: vec![], 37 | todo_accounts: vec![], 38 | invalid_accounts: vec![], 39 | order: CrawlingOrder::Random, 40 | export_time: None, 41 | characters: vec![], 42 | lvl_skipped_accounts: Default::default(), 43 | min_level: 0, 44 | max_level: 9999, 45 | }), 46 | }; 47 | 48 | let que_id = QueID::new(); 49 | let mut todo_pages = new_info.todo_pages; 50 | let invalid_pages = new_info.invalid_pages; 51 | let todo_accounts = new_info.todo_accounts; 52 | let invalid_accounts = new_info.invalid_accounts; 53 | let order = new_info.order; 54 | 55 | order.apply_order(&mut todo_pages); 56 | 57 | let mut equipment = Default::default(); 58 | let mut player_info = Default::default(); 59 | let mut naked = Default::default(); 60 | 61 | for (idx, char) in new_info.characters.into_iter().enumerate() { 62 | if idx % 10_001 == 10_000 { 63 | // This loop can take a few seconds, so we make sure this does 64 | // not block the ui by yielding after a bit 65 | yield_now().await; 66 | } 67 | handle_new_char_info( 68 | char, &mut equipment, &mut player_info, &mut naked, 69 | ); 70 | } 71 | 72 | RestoreData { 73 | que_id, 74 | player_info, 75 | equipment, 76 | todo_pages, 77 | invalid_pages, 78 | todo_accounts, 79 | invalid_accounts, 80 | order, 81 | naked, 82 | lvl_skipped_accounts: new_info.lvl_skipped_accounts, 83 | min_level: new_info.min_level, 84 | max_level: new_info.max_level, 85 | } 86 | } 87 | 88 | #[derive(Debug, Clone)] 89 | pub struct RestoreData { 90 | pub que_id: QueID, 91 | pub player_info: IntMap, 92 | pub naked: BTreeMap>, 93 | pub equipment: HashMap< 94 | EquipmentIdent, 95 | HashSet, 96 | ahash::RandomState, 97 | >, 98 | pub todo_pages: Vec, 99 | pub invalid_pages: Vec, 100 | pub todo_accounts: Vec, 101 | pub invalid_accounts: Vec, 102 | pub order: CrawlingOrder, 103 | pub lvl_skipped_accounts: BTreeMap>, 104 | pub min_level: u32, 105 | pub max_level: u32, 106 | } 107 | 108 | impl RestoreData { 109 | pub fn into_status(self) -> CrawlingStatus { 110 | CrawlingStatus::Crawling { 111 | que_id: self.que_id, 112 | threads: 0, 113 | que: Arc::new(Mutex::new(WorkerQue { 114 | que_id: self.que_id, 115 | todo_pages: self.todo_pages, 116 | invalid_pages: self.invalid_pages, 117 | todo_accounts: self.todo_accounts, 118 | invalid_accounts: self.invalid_accounts, 119 | order: self.order, 120 | in_flight_pages: vec![], 121 | in_flight_accounts: Default::default(), 122 | max_level: self.max_level, 123 | min_level: self.min_level, 124 | lvl_skipped_accounts: self.lvl_skipped_accounts, 125 | self_init: false, 126 | })), 127 | player_info: self.player_info, 128 | equipment: self.equipment, 129 | naked: self.naked, 130 | last_update: Local::now(), 131 | crawling_session: None, 132 | recent_failures: vec![], 133 | } 134 | } 135 | } 136 | 137 | pub async fn get_newest_backup( 138 | server_ident: String, 139 | fetch_online: bool, 140 | ) -> Option> { 141 | let backup = ZHofBackup::read(&server_ident).await; 142 | if let Err(e) = &backup { 143 | warn!("{server_ident} could not read in local backup: {e}") 144 | } 145 | 146 | let mut backup = backup.ok(); 147 | if !fetch_online { 148 | return backup.map(Box::new); 149 | } 150 | 151 | let online_time = fetch_online_hof_date(&server_ident).await.ok(); 152 | // Figure out, if the online version is newer, than the local backup 153 | let fetch_online = 154 | match (online_time, backup.as_ref().and_then(|a| a.export_time)) { 155 | (Some(ot), Some(bt)) => { 156 | let bt = bt.to_rfc2822(); 157 | let bt = DateTime::parse_from_rfc2822(&bt).unwrap().to_utc(); 158 | bt < ot 159 | } 160 | (Some(_), None) => true, 161 | (None, _) => false, 162 | }; 163 | debug!("{server_ident} fetch online backup: {fetch_online}"); 164 | // If the online backup is newer, we fetch it and restore it 165 | if fetch_online && fetch_online_hof(&server_ident).await.is_ok() { 166 | debug!("{server_ident} fetched online HoF"); 167 | backup = ZHofBackup::read(&server_ident).await.ok(); 168 | } 169 | backup.map(Box::new) 170 | } 171 | 172 | #[derive(Debug, Serialize, Deserialize, Clone)] 173 | pub struct ZHofBackup { 174 | #[serde(default)] 175 | pub todo_pages: Vec, 176 | #[serde(default)] 177 | pub invalid_pages: Vec, 178 | #[serde(default)] 179 | pub todo_accounts: Vec, 180 | #[serde(default)] 181 | pub invalid_accounts: Vec, 182 | #[serde(default)] 183 | pub order: CrawlingOrder, 184 | pub export_time: Option>, 185 | pub characters: Vec, 186 | #[serde(default)] 187 | pub lvl_skipped_accounts: BTreeMap>, 188 | #[serde(default)] 189 | pub min_level: u32, 190 | #[serde(default = "default_max_lvl")] 191 | pub max_level: u32, 192 | } 193 | 194 | fn default_max_lvl() -> u32 { 195 | 9999 196 | } 197 | 198 | impl ZHofBackup { 199 | pub async fn write(&self, ident: &str) -> Result<(), std::io::Error> { 200 | let serialized = serde_json::to_string(&self).unwrap(); 201 | let file = tokio::fs::File::create(format!("{}.zhof", ident)).await?; 202 | let mut encoder = ZlibEncoder::new(file); 203 | encoder.write_all(serialized.as_bytes()).await?; 204 | encoder.flush().await?; 205 | encoder.shutdown().await?; 206 | Ok(()) 207 | } 208 | 209 | pub async fn read(ident: &str) -> Result { 210 | let file = tokio::fs::File::open(format!("{}.zhof", ident)).await?; 211 | let reader = BufReader::new(file); 212 | let mut decoder = 213 | async_compression::tokio::bufread::ZlibDecoder::new(reader); 214 | let mut buffer = Vec::new(); 215 | tokio::io::AsyncReadExt::read_to_end(&mut decoder, &mut buffer).await?; 216 | 217 | let deserialized = serde_json::from_slice(&buffer)?; 218 | Ok(deserialized) 219 | } 220 | } 221 | 222 | async fn fetch_online_hof_date( 223 | server_ident: &str, 224 | ) -> Result, Box> { 225 | let resp = reqwest::get(format!( 226 | "https://hof-cache.marenga.dev/{server_ident}.version" 227 | )) 228 | .await?; 229 | 230 | match resp.error_for_status() { 231 | Ok(r) => { 232 | let text = r.text().await?; 233 | let date_time = DateTime::parse_from_rfc2822(&text)?; 234 | Ok(date_time.to_utc()) 235 | } 236 | Err(e) => Err(e.into()), 237 | } 238 | } 239 | 240 | async fn fetch_online_hof( 241 | server_ident: &str, 242 | ) -> Result<(), Box> { 243 | let resp = reqwest::get(format!( 244 | "https://hof-cache.marenga.dev/{server_ident}.zhof" 245 | )) 246 | .await?; 247 | 248 | match resp.error_for_status() { 249 | Ok(r) => { 250 | let bytes = r.bytes().await?; 251 | tokio::fs::write(format!("{server_ident}.zhof"), bytes).await?; 252 | Ok(()) 253 | } 254 | Err(e) => Err(e.into()), 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/ui/scrapbook.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | use chrono::Local; 4 | use iced::{ 5 | Alignment, Element, Length, 6 | alignment::Horizontal, 7 | theme, 8 | widget::{ 9 | Image, button, checkbox, column, horizontal_space, row, scrollable, 10 | text, vertical_space, 11 | }, 12 | }; 13 | use iced_aw::number_input; 14 | use num_format::ToFormattedString; 15 | 16 | use super::{remaining_minutes, view_crawling}; 17 | use crate::{ 18 | ClassImages, 19 | config::Config, 20 | message::Message, 21 | player::{AccountInfo, AccountStatus}, 22 | server::ServerInfo, 23 | }; 24 | 25 | pub fn view_scrapbook<'a>( 26 | server: &'a ServerInfo, 27 | player: &'a AccountInfo, 28 | config: &'a Config, 29 | images: &'a ClassImages, 30 | ) -> Element<'a, Message> { 31 | let lock = player.status.lock().unwrap(); 32 | 33 | let gs = match &*lock { 34 | AccountStatus::LoggingIn => return text("Logging in").size(20).into(), 35 | AccountStatus::Idle(_, gs) => gs, 36 | AccountStatus::Busy(gs, _) => gs, 37 | AccountStatus::FatalError(err) => { 38 | return text(format!("Error: {err}")).size(20).into(); 39 | } 40 | AccountStatus::LoggingInAgain => { 41 | return text("Logging in again".to_string()).size(20).into(); 42 | } 43 | }; 44 | 45 | let Some(si) = &player.scrapbook_info else { 46 | return text("Player does not have a scrapbook").size(20).into(); 47 | }; 48 | 49 | let mut left_col = column!().align_items(Alignment::Center).spacing(10); 50 | 51 | left_col = left_col.push(row!( 52 | text("Mushrooms:").width(Length::FillPortion(1)), 53 | text(gs.character.mushrooms) 54 | .width(Length::FillPortion(1)) 55 | .horizontal_alignment(Horizontal::Right) 56 | )); 57 | 58 | left_col = left_col.push(row!( 59 | text("Items Found:").width(Length::FillPortion(1)), 60 | text( 61 | si.scrapbook 62 | .items 63 | .len() 64 | .to_formatted_string(&config.num_format) 65 | ) 66 | .width(Length::FillPortion(1)) 67 | .horizontal_alignment(Horizontal::Right) 68 | )); 69 | 70 | left_col = left_col.push(row!( 71 | text("Total Attributes:").width(Length::FillPortion(1)), 72 | text( 73 | (gs.character.attribute_basis.as_array().iter().sum::() 74 | + gs.character 75 | .attribute_additions 76 | .as_array() 77 | .iter() 78 | .sum::()) 79 | .to_formatted_string(&config.num_format) 80 | ) 81 | .width(Length::FillPortion(1)) 82 | .horizontal_alignment(Horizontal::Right) 83 | )); 84 | 85 | left_col = left_col.push(row!( 86 | text("Level:").width(Length::FillPortion(1)), 87 | text(gs.character.level) 88 | .width(Length::FillPortion(1)) 89 | .horizontal_alignment(Horizontal::Right) 90 | )); 91 | 92 | let aid = player.ident; 93 | let max_lvl = 94 | number_input(si.max_level, 9999, move |nv| Message::PlayerSetMaxLvl { 95 | ident: aid, 96 | max: nv, 97 | }) 98 | .style(iced_aw::NumberInputStyles::Default); 99 | 100 | let max_lvl = row!(text("Max Level:"), horizontal_space(), max_lvl) 101 | .align_items(Alignment::Center); 102 | left_col = left_col.push(max_lvl); 103 | 104 | let max_attributes = 105 | number_input(si.max_attributes, 9_999_999, move |nv| { 106 | Message::PlayerSetMaxAttributes { 107 | ident: aid, 108 | max: nv, 109 | } 110 | }) 111 | .style(iced_aw::NumberInputStyles::Default); 112 | 113 | let max_attributes = 114 | row!(text("Max Attributes:"), horizontal_space(), max_attributes) 115 | .align_items(Alignment::Center); 116 | left_col = left_col.push(max_attributes); 117 | 118 | match &gs.arena.next_free_fight { 119 | Some(x) if *x >= Local::now() => { 120 | let t = text("Next free fight:"); 121 | let r = row!( 122 | t.width(Length::FillPortion(1)), 123 | text(remaining_minutes(*x)) 124 | .width(Length::FillPortion(1)) 125 | .horizontal_alignment(Horizontal::Right) 126 | ); 127 | left_col = left_col.push(r); 128 | } 129 | _ => left_col = left_col.push("Free fight possible"), 130 | }; 131 | 132 | left_col = left_col.push( 133 | checkbox("Auto Battle", si.auto_battle) 134 | .on_toggle(|a| Message::AutoBattle { 135 | ident: player.ident, 136 | state: a, 137 | }) 138 | .size(20), 139 | ); 140 | 141 | left_col = left_col.push(button("Copy Optimal Battle Order").on_press( 142 | Message::CopyBattleOrder { 143 | ident: player.ident, 144 | }, 145 | )); 146 | 147 | if !si.attack_log.is_empty() { 148 | let mut log = column!().padding(5).spacing(5); 149 | 150 | for (time, target, won) in si.attack_log.iter().rev() { 151 | let time = text(format!("{}", time.time().format("%H:%M"))); 152 | let mut info = target.info.name.to_string(); 153 | if *won { 154 | _ = info.write_fmt(format_args!(" (+{})", target.missing)); 155 | } 156 | let target = text(&info); 157 | let row = button(row!(target, horizontal_space(), time)).style( 158 | match won { 159 | true => theme::Button::Positive, 160 | false => theme::Button::Destructive, 161 | }, 162 | ); 163 | log = log.push(row.padding(5)); 164 | } 165 | 166 | left_col = left_col.push(scrollable(log).height(Length::Fixed(200.0))); 167 | } 168 | left_col = left_col.push(vertical_space()); 169 | 170 | left_col = left_col.push(view_crawling(server, config)); 171 | 172 | let mut name_bar = column!(); 173 | name_bar = name_bar.push(row!( 174 | text("") 175 | .width(Length::FillPortion(5)) 176 | .horizontal_alignment(Horizontal::Center), 177 | text("Missing") 178 | .width(Length::FillPortion(5)) 179 | .horizontal_alignment(Horizontal::Center), 180 | text("Level") 181 | .width(Length::FillPortion(5)) 182 | .horizontal_alignment(Horizontal::Center), 183 | text("Attributes") 184 | .width(Length::FillPortion(5)) 185 | .horizontal_alignment(Horizontal::Center), 186 | text("Name") 187 | .width(Length::FillPortion(15)) 188 | .horizontal_alignment(Horizontal::Left), 189 | )); 190 | let name_bar = scrollable(name_bar); 191 | 192 | let mut target_list = column!().spacing(10); 193 | for v in &si.best { 194 | let mut target_ident = row!() 195 | .align_items(Alignment::Start) 196 | .spacing(5) 197 | .width(Length::FillPortion(15)); 198 | 199 | if let Some(class) = v.info.class 200 | && config.show_class_icons 201 | { 202 | let img = Image::new(images.get_handle(class)) 203 | .width(Length::FillPortion(1)) 204 | .content_fit(iced::ContentFit::ScaleDown); 205 | target_ident = target_ident.push(img); 206 | } 207 | target_ident = target_ident.push( 208 | text(&v.info.name) 209 | .width(Length::FillPortion(15)) 210 | .horizontal_alignment(Horizontal::Left), 211 | ); 212 | 213 | target_list = target_list.push(row!( 214 | column!(button("Attack").on_press(Message::PlayerAttack { 215 | ident: player.ident, 216 | target: v.to_owned() 217 | })) 218 | .align_items(Alignment::Center) 219 | .width(Length::FillPortion(5)), 220 | text(v.missing) 221 | .width(Length::FillPortion(5)) 222 | .horizontal_alignment(Horizontal::Center), 223 | text(v.info.level) 224 | .width(Length::FillPortion(5)) 225 | .horizontal_alignment(Horizontal::Center), 226 | text( 227 | v.info 228 | .stats 229 | .map(|a| a.to_formatted_string(&config.num_format)) 230 | .unwrap_or("???".to_string()) 231 | ) 232 | .width(Length::FillPortion(5)) 233 | .horizontal_alignment(Horizontal::Center), 234 | target_ident, 235 | )); 236 | } 237 | let target_list = scrollable(target_list); 238 | let right_col = column!(name_bar, target_list).spacing(10); 239 | 240 | row!( 241 | left_col.width(Length::Fixed(200.0)), 242 | right_col.width(Length::Fill) 243 | ) 244 | .padding(15) 245 | .height(Length::Fill) 246 | .align_items(Alignment::Start) 247 | .into() 248 | } 249 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use iced::Theme; 2 | use num_format::CustomFormat; 3 | use serde::{Deserialize, Serialize}; 4 | use sf_api::session::PWHash; 5 | 6 | use crate::{ServerID, server::ServerIdent}; 7 | 8 | #[derive(Debug, Serialize, Deserialize, Clone)] 9 | pub struct Config { 10 | pub accounts: Vec, 11 | pub theme: AvailableTheme, 12 | pub base_name: String, 13 | pub auto_fetch_newest: bool, 14 | #[serde(default)] 15 | pub auto_poll: bool, 16 | #[serde(default = "default_threads")] 17 | pub max_threads: usize, 18 | #[serde(default = "default_start_threads")] 19 | pub start_threads: usize, 20 | #[serde(default)] 21 | pub show_crawling_restrict: bool, 22 | #[serde(default = "default_class_icons")] 23 | pub show_class_icons: bool, 24 | #[serde(default = "default_blacklist_threshhold")] 25 | pub blacklist_threshold: usize, 26 | 27 | #[serde(default = "default_locale", skip)] 28 | pub num_format: CustomFormat, 29 | } 30 | 31 | fn default_threads() -> usize { 32 | 10 33 | } 34 | 35 | fn default_start_threads() -> usize { 36 | 1 37 | } 38 | 39 | fn default_locale() -> CustomFormat { 40 | let mut cfb = CustomFormat::builder(); 41 | cfb = cfb.separator(","); 42 | cfb.build().unwrap_or_default() 43 | } 44 | 45 | fn default_blacklist_threshhold() -> usize { 46 | 2 47 | } 48 | 49 | fn default_class_icons() -> bool { 50 | true 51 | } 52 | 53 | impl Default for Config { 54 | fn default() -> Self { 55 | let mut rng = fastrand::Rng::new(); 56 | let mut base_name = rng.alphabetic().to_ascii_uppercase().to_string(); 57 | for _ in 0..rng.u32(6..8) { 58 | let c = if rng.bool() { 59 | rng.alphabetic() 60 | } else { 61 | rng.digit(10) 62 | }; 63 | base_name.push(c) 64 | } 65 | 66 | Self { 67 | accounts: vec![], 68 | theme: AvailableTheme::Dark, 69 | base_name, 70 | auto_fetch_newest: true, 71 | max_threads: default_threads(), 72 | auto_poll: false, 73 | show_crawling_restrict: false, 74 | show_class_icons: true, 75 | blacklist_threshold: default_blacklist_threshhold(), 76 | num_format: default_locale(), 77 | start_threads: default_start_threads(), 78 | } 79 | } 80 | } 81 | 82 | impl Config { 83 | pub fn get_sso_accounts_mut( 84 | &mut self, 85 | name: &str, 86 | ) -> Option<&mut Vec> { 87 | let lower_name = name.to_lowercase(); 88 | self.accounts 89 | .iter_mut() 90 | .flat_map(|a| match a { 91 | AccountConfig::SF { 92 | name, characters, .. 93 | } if name.to_lowercase().trim() == lower_name.trim() => { 94 | Some(characters) 95 | } 96 | _ => None, 97 | }) 98 | .next() 99 | } 100 | 101 | pub fn get_char_conf( 102 | &self, 103 | name: &str, 104 | og_server: ServerID, 105 | ) -> Option<&CharacterConfig> { 106 | let mut res = None; 107 | 108 | let lower_name = name.to_lowercase(); 109 | for acc in &self.accounts { 110 | match acc { 111 | AccountConfig::Regular { 112 | name, 113 | server, 114 | config, 115 | .. 116 | } => { 117 | if ServerIdent::new(server).id != og_server { 118 | continue; 119 | } 120 | if name.to_lowercase().trim() != lower_name.trim() { 121 | continue; 122 | } 123 | res = Some(config); 124 | break; 125 | } 126 | AccountConfig::SF { characters, .. } => { 127 | for c in characters { 128 | if ServerIdent::new(&c.ident.server).id != og_server { 129 | continue; 130 | } 131 | if c.ident.name.to_lowercase().trim() 132 | != lower_name.trim() 133 | { 134 | continue; 135 | } 136 | res = Some(&c.config); 137 | } 138 | } 139 | } 140 | } 141 | res 142 | } 143 | 144 | pub fn get_char_conf_mut( 145 | &mut self, 146 | name: &str, 147 | og_server: ServerID, 148 | ) -> Option<&mut CharacterConfig> { 149 | let mut res = None; 150 | 151 | let lower_name = name.to_lowercase(); 152 | for acc in &mut self.accounts { 153 | match acc { 154 | AccountConfig::Regular { 155 | name, 156 | server, 157 | config, 158 | .. 159 | } => { 160 | if ServerIdent::new(server).id != og_server { 161 | continue; 162 | } 163 | if name.to_lowercase().trim() != lower_name.trim() { 164 | continue; 165 | } 166 | res = Some(config); 167 | break; 168 | } 169 | AccountConfig::SF { characters, .. } => { 170 | for c in characters { 171 | if ServerIdent::new(&c.ident.server).id != og_server { 172 | continue; 173 | } 174 | if c.ident.name.to_lowercase().trim() 175 | != lower_name.trim() 176 | { 177 | continue; 178 | } 179 | res = Some(&mut c.config); 180 | } 181 | } 182 | } 183 | } 184 | res 185 | } 186 | 187 | pub fn write(&self) -> Result<(), Box> { 188 | let str = toml::to_string_pretty(self)?; 189 | std::fs::write("helper.toml", str)?; 190 | Ok(()) 191 | } 192 | pub fn restore() -> Result> { 193 | let val = std::fs::read_to_string("helper.toml")?; 194 | Ok(toml::from_str(&val)?) 195 | } 196 | } 197 | 198 | #[derive(Debug, Serialize, Deserialize, Clone)] 199 | #[serde(untagged)] 200 | pub enum AccountCreds { 201 | Regular { 202 | name: String, 203 | pw_hash: PWHash, 204 | server: String, 205 | }, 206 | SF { 207 | name: String, 208 | pw_hash: PWHash, 209 | }, 210 | } 211 | 212 | impl From for AccountCreds { 213 | fn from(value: AccountConfig) -> Self { 214 | match value { 215 | AccountConfig::Regular { 216 | name, 217 | pw_hash, 218 | server, 219 | .. 220 | } => AccountCreds::Regular { 221 | name, 222 | pw_hash, 223 | server, 224 | }, 225 | AccountConfig::SF { name, pw_hash, .. } => { 226 | AccountCreds::SF { name, pw_hash } 227 | } 228 | } 229 | } 230 | } 231 | 232 | #[derive(Debug, Serialize, Deserialize, Clone)] 233 | #[serde(untagged)] 234 | pub enum AccountConfig { 235 | Regular { 236 | name: String, 237 | pw_hash: PWHash, 238 | server: String, 239 | #[serde(flatten)] 240 | config: CharacterConfig, 241 | }, 242 | SF { 243 | name: String, 244 | pw_hash: PWHash, 245 | #[serde(default)] 246 | characters: Vec, 247 | }, 248 | } 249 | 250 | #[derive(Debug, Serialize, Deserialize, Clone)] 251 | pub struct SFAccCharacter { 252 | pub ident: SFCharIdent, 253 | pub config: CharacterConfig, 254 | } 255 | 256 | #[derive(Debug, Serialize, Deserialize, Clone, Default)] 257 | pub struct CharacterConfig { 258 | #[serde(default)] 259 | pub login: bool, 260 | #[serde(default)] 261 | pub auto_battle: bool, 262 | #[serde(default)] 263 | pub auto_lure: bool, 264 | } 265 | 266 | #[derive(Debug, Serialize, Deserialize, Clone, Hash, PartialEq, Eq)] 267 | pub struct SFCharIdent { 268 | pub name: String, 269 | pub server: String, 270 | } 271 | 272 | impl AccountConfig { 273 | pub fn new(creds: AccountCreds) -> AccountConfig { 274 | match creds { 275 | AccountCreds::Regular { 276 | name, 277 | pw_hash, 278 | server, 279 | } => AccountConfig::Regular { 280 | name, 281 | pw_hash, 282 | server, 283 | config: Default::default(), 284 | }, 285 | AccountCreds::SF { name, pw_hash } => AccountConfig::SF { 286 | name, 287 | pw_hash, 288 | characters: Default::default(), 289 | }, 290 | } 291 | } 292 | } 293 | 294 | #[derive( 295 | Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, 296 | )] 297 | pub enum AvailableTheme { 298 | Light, 299 | #[default] 300 | Dark, 301 | Dracula, 302 | Nord, 303 | SolarizedLight, 304 | SolarizedDark, 305 | GruvboxLight, 306 | GruvboxDark, 307 | CatppuccinLatte, 308 | CatppuccinFrappe, 309 | CatppuccinMacchiato, 310 | CatppuccinMocha, 311 | TokyoNight, 312 | TokyoNightStorm, 313 | TokyoNightLight, 314 | KanagawaWave, 315 | KanagawaDragon, 316 | KanagawaLotus, 317 | Moonfly, 318 | Nightfly, 319 | Oxocarbon, 320 | } 321 | 322 | #[allow(clippy::to_string_trait_impl)] 323 | impl ToString for AvailableTheme { 324 | fn to_string(&self) -> String { 325 | use AvailableTheme::*; 326 | match self { 327 | Light => Theme::Light, 328 | Dark => Theme::Dark, 329 | Dracula => Theme::Dracula, 330 | Nord => Theme::Nord, 331 | SolarizedLight => Theme::SolarizedLight, 332 | SolarizedDark => Theme::SolarizedDark, 333 | GruvboxLight => Theme::GruvboxLight, 334 | GruvboxDark => Theme::GruvboxDark, 335 | CatppuccinLatte => Theme::CatppuccinLatte, 336 | CatppuccinFrappe => Theme::CatppuccinFrappe, 337 | CatppuccinMacchiato => Theme::CatppuccinMacchiato, 338 | CatppuccinMocha => Theme::CatppuccinMocha, 339 | TokyoNight => Theme::TokyoNight, 340 | TokyoNightStorm => Theme::TokyoNightStorm, 341 | TokyoNightLight => Theme::TokyoNightLight, 342 | KanagawaWave => Theme::KanagawaWave, 343 | KanagawaDragon => Theme::KanagawaDragon, 344 | KanagawaLotus => Theme::KanagawaLotus, 345 | Moonfly => Theme::Moonfly, 346 | Nightfly => Theme::Nightfly, 347 | Oxocarbon => Theme::Oxocarbon, 348 | } 349 | .to_string() 350 | } 351 | } 352 | 353 | impl AvailableTheme { 354 | pub fn theme(&self) -> Theme { 355 | use AvailableTheme::*; 356 | 357 | match self { 358 | Light => Theme::Light, 359 | Dark => Theme::Dark, 360 | Dracula => Theme::Dracula, 361 | Nord => Theme::Nord, 362 | SolarizedLight => Theme::SolarizedLight, 363 | SolarizedDark => Theme::SolarizedDark, 364 | GruvboxLight => Theme::GruvboxLight, 365 | GruvboxDark => Theme::GruvboxDark, 366 | CatppuccinLatte => Theme::CatppuccinLatte, 367 | CatppuccinFrappe => Theme::CatppuccinFrappe, 368 | CatppuccinMacchiato => Theme::CatppuccinMacchiato, 369 | CatppuccinMocha => Theme::CatppuccinMocha, 370 | TokyoNight => Theme::TokyoNight, 371 | TokyoNightStorm => Theme::TokyoNightStorm, 372 | TokyoNightLight => Theme::TokyoNightLight, 373 | KanagawaWave => Theme::KanagawaWave, 374 | KanagawaDragon => Theme::KanagawaDragon, 375 | KanagawaLotus => Theme::KanagawaLotus, 376 | Moonfly => Theme::Moonfly, 377 | Nightfly => Theme::Nightfly, 378 | Oxocarbon => Theme::Oxocarbon, 379 | } 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /src/crawler.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::{Arc, Mutex}, 3 | time::{Duration, SystemTime}, 4 | }; 5 | 6 | use chrono::Utc; 7 | use sf_api::{ 8 | error::SFError, 9 | gamestate::{GameState, character::*}, 10 | session::*, 11 | }; 12 | use tokio::{sync::RwLock, time::sleep}; 13 | 14 | use self::backup::ZHofBackup; 15 | use crate::*; 16 | 17 | pub struct Crawler { 18 | pub que: Arc>, 19 | pub state: Arc, 20 | pub server_id: ServerID, 21 | } 22 | 23 | impl Crawler { 24 | pub async fn crawl(&mut self) -> Message { 25 | let action = { 26 | // Thi: CrawlActions is in a seperate scope to immediately drop the 27 | // guard 28 | let mut lock = self.que.lock().unwrap(); 29 | loop { 30 | match lock.todo_accounts.pop() { 31 | Some(entry) => { 32 | if entry.chars().all(|a| a.is_ascii_digit()) { 33 | // We will get a wrong result here, because 34 | // fetching them will be seen as a request to view 35 | // a player by id, not by name 36 | lock.invalid_accounts.push(entry); 37 | continue; 38 | } 39 | lock.in_flight_accounts.insert(entry.clone()); 40 | break CrawlAction::Character(entry, lock.que_id); 41 | } 42 | None => match lock.todo_pages.pop() { 43 | Some(idx) => { 44 | lock.in_flight_pages.push(idx); 45 | break CrawlAction::Page(idx, lock.que_id); 46 | } 47 | None => { 48 | if lock.self_init { 49 | lock.self_init = false; 50 | break CrawlAction::InitTodo; 51 | } else { 52 | break CrawlAction::Wait; 53 | } 54 | } 55 | }, 56 | } 57 | } 58 | }; 59 | 60 | use sf_api::command::Command; 61 | let session = self.state.session.read().await; 62 | match &action { 63 | CrawlAction::Wait => { 64 | drop(session); 65 | sleep(Duration::from_secs(1)).await; 66 | Message::CrawlerIdle(self.server_id) 67 | } 68 | CrawlAction::Page(page, _) => { 69 | let cmd = Command::HallOfFamePage { page: *page }; 70 | let resp = match session.send_command_raw(&cmd).await { 71 | Ok(resp) => resp, 72 | Err(e) => { 73 | let error = CrawlerError::from_err(e); 74 | if error == CrawlerError::RateLimit { 75 | sleep_until_rate_limit_reset().await; 76 | } 77 | return Message::CrawlerUnable { 78 | server: self.server_id, 79 | action, 80 | error, 81 | }; 82 | } 83 | }; 84 | drop(session); 85 | let mut gs = self.state.gs.lock().unwrap(); 86 | if let Err(e) = gs.update(resp) { 87 | let error = CrawlerError::from_err(e); 88 | return Message::CrawlerUnable { 89 | server: self.server_id, 90 | action, 91 | error, 92 | }; 93 | }; 94 | 95 | let mut lock = self.que.lock().unwrap(); 96 | for acc in gs.hall_of_fames.players.drain(..) { 97 | if acc.level > lock.max_level || acc.level < lock.min_level 98 | { 99 | match lock.lvl_skipped_accounts.entry(acc.level) { 100 | std::collections::btree_map::Entry::Vacant(vac) => { 101 | vac.insert(vec![acc.name]); 102 | } 103 | std::collections::btree_map::Entry::Occupied( 104 | mut occ, 105 | ) => occ.get_mut().push(acc.name), 106 | } 107 | } else { 108 | lock.todo_accounts.push(acc.name); 109 | } 110 | } 111 | lock.in_flight_pages.retain(|a| a != page); 112 | Message::PageCrawled 113 | } 114 | CrawlAction::Character(name, que_id) => { 115 | let cmd = Command::ViewPlayer { 116 | ident: name.clone(), 117 | }; 118 | let resp = match session.send_command_raw(&cmd).await { 119 | Ok(resp) => resp, 120 | Err(e) => { 121 | let error = CrawlerError::from_err(e); 122 | if error == CrawlerError::RateLimit { 123 | sleep_until_rate_limit_reset().await; 124 | } 125 | return Message::CrawlerUnable { 126 | server: self.server_id, 127 | action, 128 | error, 129 | }; 130 | } 131 | }; 132 | drop(session); 133 | let mut gs = self.state.gs.lock().unwrap(); 134 | if let Err(e) = gs.update(&resp) { 135 | let error = CrawlerError::from_err(e); 136 | return Message::CrawlerUnable { 137 | server: self.server_id, 138 | action, 139 | error, 140 | }; 141 | } 142 | 143 | let character = match gs.lookup.remove_name(name) { 144 | Some(player) => { 145 | let equipment = player 146 | .equipment 147 | .0 148 | .as_array() 149 | .iter() 150 | .flatten() 151 | .filter_map(|a| a.equipment_ident()) 152 | .collect(); 153 | let stats = player 154 | .base_attributes 155 | .as_array() 156 | .iter() 157 | .sum::() 158 | + player 159 | .bonus_attributes 160 | .as_array() 161 | .iter() 162 | .sum::(); 163 | CharacterInfo { 164 | equipment, 165 | name: player.name, 166 | uid: player.player_id, 167 | level: player.level, 168 | fetch_date: Some(Utc::now().date_naive()), 169 | stats: Some(stats), 170 | class: Some(player.class), 171 | } 172 | } 173 | None => { 174 | drop(gs); 175 | let mut lock = self.que.lock().unwrap(); 176 | if lock.que_id == *que_id { 177 | lock.invalid_accounts.retain(|a| a != name); 178 | lock.in_flight_accounts.remove(name); 179 | lock.invalid_accounts.push(name.to_string()); 180 | } 181 | return Message::CrawlerNoPlayerResult; 182 | } 183 | }; 184 | Message::CharacterCrawled { 185 | server: self.server_id, 186 | que_id: *que_id, 187 | character, 188 | } 189 | } 190 | CrawlAction::InitTodo => { 191 | drop(session); 192 | let gs = self.state.gs.lock().unwrap(); 193 | let pages = (gs.hall_of_fames.players_total as usize) 194 | .div_ceil(PER_PAGE); 195 | drop(gs); 196 | let mut que = self.que.lock().unwrap(); 197 | que.todo_pages = (0..pages).collect(); 198 | let order = que.order; 199 | order.apply_order(&mut que.todo_pages); 200 | Message::CrawlerIdle(self.server_id) 201 | } 202 | } 203 | } 204 | } 205 | 206 | #[derive(Debug)] 207 | pub struct CrawlerState { 208 | pub session: RwLock, 209 | pub gs: Mutex, 210 | } 211 | impl CrawlerState { 212 | pub async fn try_login( 213 | name: String, 214 | server: ServerConnection, 215 | ) -> Result { 216 | let password = name.chars().rev().collect::(); 217 | let mut session = Session::new(&name, &password, server.clone()); 218 | debug!("Logging in {name} on {}", session.server_url()); 219 | if let Ok(resp) = session.login().await { 220 | debug!("Successfully logged in {name} on {}", session.server_url()); 221 | let gs = GameState::new(resp)?; 222 | sleep(Duration::from_secs(3)).await; 223 | return Ok(Self { 224 | session: RwLock::new(session), 225 | gs: Mutex::new(gs), 226 | }); 227 | }; 228 | 229 | let all_races = [ 230 | Race::Human, 231 | Race::Elf, 232 | Race::Dwarf, 233 | Race::Gnome, 234 | Race::Orc, 235 | Race::DarkElf, 236 | Race::Goblin, 237 | Race::Demon, 238 | ]; 239 | 240 | let all_classes = [ 241 | Class::Warrior, 242 | Class::Mage, 243 | Class::Scout, 244 | Class::Assassin, 245 | Class::BattleMage, 246 | Class::Berserker, 247 | Class::DemonHunter, 248 | Class::Druid, 249 | Class::Bard, 250 | Class::Necromancer, 251 | ]; 252 | 253 | let mut rng = fastrand::Rng::new(); 254 | let gender = rng.choice([Gender::Female, Gender::Male]).unwrap(); 255 | let race = rng.choice(all_races).unwrap(); 256 | let class = rng.choice(all_classes).unwrap(); 257 | debug!( 258 | "Registering new crawler account {name} on {}", 259 | session.server_url() 260 | ); 261 | 262 | let (session, resp) = Session::register( 263 | &name, 264 | &password, 265 | server.clone(), 266 | gender, 267 | race, 268 | class, 269 | ) 270 | .await?; 271 | let gs = GameState::new(resp)?; 272 | 273 | debug!("Registered {name} successfull {}", session.server_url()); 274 | 275 | Ok(Self { 276 | session: RwLock::new(session), 277 | gs: Mutex::new(gs), 278 | }) 279 | } 280 | } 281 | 282 | #[derive(Debug, Clone)] 283 | pub enum CrawlAction { 284 | Wait, 285 | InitTodo, 286 | Page(usize, QueID), 287 | Character(String, QueID), 288 | } 289 | 290 | impl std::fmt::Display for CrawlAction { 291 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 292 | match self { 293 | CrawlAction::Wait => f.write_str("Waiting"), 294 | CrawlAction::InitTodo => f.write_str("Inititialization"), 295 | CrawlAction::Page(page, _) => { 296 | f.write_fmt(format_args!("Fetch page {page}")) 297 | } 298 | CrawlAction::Character(name, _) => { 299 | f.write_fmt(format_args!("Fetch char {name}")) 300 | } 301 | } 302 | } 303 | } 304 | 305 | #[derive( 306 | Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, 307 | )] 308 | pub enum CrawlingOrder { 309 | #[default] 310 | Random, 311 | TopDown, 312 | BottomUp, 313 | } 314 | 315 | impl CrawlingOrder { 316 | pub fn apply_order(&self, todo_pages: &mut [usize]) { 317 | match self { 318 | CrawlingOrder::Random => fastrand::shuffle(todo_pages), 319 | CrawlingOrder::TopDown => { 320 | todo_pages.sort_by(|a, b| a.cmp(b).reverse()); 321 | } 322 | CrawlingOrder::BottomUp => todo_pages.sort(), 323 | } 324 | } 325 | } 326 | 327 | #[allow(clippy::to_string_trait_impl)] 328 | impl ToString for CrawlingOrder { 329 | fn to_string(&self) -> String { 330 | match self { 331 | CrawlingOrder::Random => "Random", 332 | CrawlingOrder::TopDown => "Top Down", 333 | CrawlingOrder::BottomUp => "Bottom Up", 334 | } 335 | .to_string() 336 | } 337 | } 338 | 339 | #[derive(Debug)] 340 | pub struct WorkerQue { 341 | pub que_id: QueID, 342 | pub todo_pages: Vec, 343 | pub todo_accounts: Vec, 344 | pub invalid_pages: Vec, 345 | pub invalid_accounts: Vec, 346 | pub in_flight_pages: Vec, 347 | pub in_flight_accounts: HashSet, 348 | pub order: CrawlingOrder, 349 | pub lvl_skipped_accounts: BTreeMap>, 350 | pub min_level: u32, 351 | pub max_level: u32, 352 | pub self_init: bool, 353 | } 354 | 355 | impl WorkerQue { 356 | pub fn create_backup( 357 | &self, 358 | player_info: &IntMap, 359 | ) -> ZHofBackup { 360 | let mut backup = ZHofBackup { 361 | todo_pages: self.todo_pages.to_owned(), 362 | invalid_pages: self.invalid_pages.to_owned(), 363 | todo_accounts: self.todo_accounts.to_owned(), 364 | invalid_accounts: self.invalid_accounts.to_owned(), 365 | order: self.order, 366 | export_time: Some(Utc::now()), 367 | characters: player_info.values().cloned().collect(), 368 | lvl_skipped_accounts: self.lvl_skipped_accounts.clone(), 369 | min_level: self.min_level, 370 | max_level: self.max_level, 371 | }; 372 | 373 | for acc in &self.in_flight_accounts { 374 | backup.todo_accounts.push(acc.to_string()) 375 | } 376 | 377 | for page in &self.in_flight_pages { 378 | backup.todo_pages.push(*page) 379 | } 380 | 381 | backup 382 | } 383 | 384 | pub fn count_remaining(&self) -> usize { 385 | self.todo_pages.len() * PER_PAGE 386 | + self.todo_accounts.len() 387 | + self.in_flight_pages.len() * PER_PAGE 388 | + self.in_flight_accounts.len() 389 | } 390 | } 391 | 392 | #[derive(Debug, Clone, PartialEq, Eq)] 393 | pub enum CrawlerError { 394 | Generic(Box), 395 | NotFound, 396 | RateLimit, 397 | } 398 | 399 | impl CrawlerError { 400 | #[allow(clippy::single_match)] 401 | pub fn from_err(value: SFError) -> Self { 402 | match &value { 403 | SFError::ServerError(serr) => match serr.as_str() { 404 | "cannot do this right now2" => return CrawlerError::RateLimit, 405 | "player not found" => { 406 | return CrawlerError::NotFound; 407 | } 408 | _ => {} 409 | }, 410 | _ => {} 411 | } 412 | CrawlerError::Generic(value.to_string().into()) 413 | } 414 | } 415 | 416 | async fn sleep_until_rate_limit_reset() { 417 | let now = SystemTime::now() 418 | .duration_since(SystemTime::UNIX_EPOCH) 419 | .expect("Time went backwards"); 420 | 421 | let mut timeout = 60 - (now.as_secs() % 60); 422 | 423 | if timeout == 0 || timeout == 59 { 424 | timeout = 1; 425 | } 426 | 427 | // make sure we dont cause a thundering herd (everyone sending requests at 428 | // exactly :00s) 429 | timeout += fastrand::u64(1..40); 430 | 431 | sleep(Duration::from_secs(timeout)).await; 432 | } 433 | -------------------------------------------------------------------------------- /src/login.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::{Arc, Mutex, atomic::AtomicU64}, 3 | time::Duration, 4 | }; 5 | 6 | use iced::{ 7 | Alignment, Command, Element, Length, Renderer, Theme, theme, 8 | widget::{ 9 | self, button, checkbox, column, container, horizontal_space, row, text, 10 | text_input, 11 | }, 12 | }; 13 | use sf_api::{ 14 | error::SFError, 15 | gamestate::GameState, 16 | session::{PWHash, ServerConnection, Session}, 17 | sso::{SFAccount, SSOAuth, SSOProvider}, 18 | }; 19 | use tokio::time::sleep; 20 | 21 | use crate::{ 22 | AccountID, AccountIdent, AccountInfo, AccountPage, Helper, ServerIdent, 23 | View, config::AccountConfig, get_server_code, message::Message, top_bar, 24 | }; 25 | 26 | pub struct LoginState { 27 | pub login_typ: LoginType, 28 | pub name: String, 29 | pub password: String, 30 | pub server: String, 31 | pub remember_me: bool, 32 | pub error: Option, 33 | pub active_sso: Vec, 34 | pub import_que: Vec, 35 | pub google_sso: Arc>, 36 | pub steam_sso: Arc>, 37 | } 38 | 39 | pub enum SSOStatus { 40 | Waiting { url: String }, 41 | Initializing, 42 | } 43 | 44 | #[derive(Debug)] 45 | pub struct SSOLogin { 46 | pub ident: SSOIdent, 47 | pub status: SSOLoginStatus, 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq, Eq)] 51 | pub enum SSOIdent { 52 | SF(String), 53 | Google(String), 54 | Steam(String), 55 | } 56 | 57 | #[derive(Debug)] 58 | pub enum SSOLoginStatus { 59 | Loading, 60 | Success, 61 | } 62 | 63 | impl LoginState { 64 | pub fn view( 65 | &self, 66 | accounts: &[AccountConfig], 67 | has_active: bool, 68 | ) -> Element<'_, Message> { 69 | let login_type_button = |label, filter, current_filter| { 70 | let label: widget::text::Text<'_, Theme, Renderer> = text(label); 71 | let button = button(label).style(if filter == current_filter { 72 | theme::Button::Primary 73 | } else { 74 | theme::Button::Secondary 75 | }); 76 | button 77 | .on_press(Message::LoginViewChanged(filter)) 78 | .padding(4) 79 | }; 80 | 81 | let mut login_selection = row![ 82 | login_type_button("Regular", LoginType::Regular, self.login_typ), 83 | login_type_button( 84 | "S&F Account", 85 | LoginType::SFAccount, 86 | self.login_typ 87 | ), 88 | login_type_button("Steam", LoginType::Steam, self.login_typ), 89 | login_type_button("Google", LoginType::Google, self.login_typ), 90 | ] 91 | .align_items(Alignment::Center) 92 | .spacing(10); 93 | 94 | if !accounts.is_empty() { 95 | login_selection = login_selection.push(login_type_button( 96 | "Saved", 97 | LoginType::Saved, 98 | self.login_typ, 99 | )) 100 | } 101 | 102 | if !self.active_sso.is_empty() { 103 | login_selection = login_selection.push(login_type_button( 104 | "SSO", 105 | LoginType::SSOAccounts, 106 | self.login_typ, 107 | )) 108 | } 109 | 110 | if !self.import_que.is_empty() { 111 | login_selection = login_selection.push(login_type_button( 112 | "SSO Chars", 113 | LoginType::SSOChars, 114 | self.login_typ, 115 | )) 116 | } 117 | 118 | let top_bar = top_bar( 119 | login_selection.into(), 120 | if has_active { 121 | Some(Message::ViewOverview) 122 | } else { 123 | None 124 | }, 125 | ); 126 | 127 | let current_login = match self.login_typ { 128 | LoginType::SFAccount => { 129 | let title = text("S&F Account Login").size(20); 130 | let name_input = text_input("Username", &self.name) 131 | .on_input(Message::LoginNameInputChange); 132 | 133 | let pw_input = text_input("Password", &self.password) 134 | .on_input(Message::LoginPWInputChange) 135 | .on_submit(Message::LoginSFSubmit) 136 | .secure(true); 137 | 138 | let sso_login_button = 139 | button("Login").on_press(Message::LoginSFSubmit).padding(4); 140 | 141 | let remember_me = checkbox("Remember me", self.remember_me) 142 | .on_toggle(Message::RememberMe); 143 | let options_row = row!(remember_me) 144 | .width(Length::Fill) 145 | .align_items(Alignment::Start); 146 | let error_msg = row!( 147 | horizontal_space(), 148 | text( 149 | self.error 150 | .as_ref() 151 | .map(|a| format!("Error: {a}")) 152 | .unwrap_or_default() 153 | ), 154 | horizontal_space() 155 | ) 156 | .width(Length::Fill) 157 | .align_items(Alignment::Center); 158 | 159 | column![ 160 | title, name_input, pw_input, options_row, sso_login_button, 161 | error_msg 162 | ] 163 | } 164 | LoginType::Regular => { 165 | let title: widget::text::Text<'_, Theme, Renderer> = 166 | text("Login").size(20); 167 | let name_input = text_input("Username", &self.name) 168 | .on_input(Message::LoginNameInputChange); 169 | let pw_input = text_input("Password", &self.password) 170 | .on_input(Message::LoginPWInputChange) 171 | .on_submit(Message::LoginRegularSubmit) 172 | .secure(true); 173 | let server_input = text_input("f1.sfgame.net", &self.server) 174 | .on_input(Message::LoginServerChange) 175 | .on_submit(Message::LoginRegularSubmit); 176 | let regular_login_button = button("Login") 177 | .on_press(Message::LoginRegularSubmit) 178 | .padding(4); 179 | 180 | let remember_me = checkbox("Remember me", self.remember_me) 181 | .on_toggle(Message::RememberMe); 182 | let options_row = row!(remember_me) 183 | .width(Length::Fill) 184 | .align_items(Alignment::Start); 185 | 186 | column![ 187 | title, name_input, pw_input, server_input, options_row, 188 | regular_login_button 189 | ] 190 | } 191 | LoginType::Steam => { 192 | let title: widget::text::Text<'_, Theme, Renderer> = 193 | text("Steam").size(20); 194 | 195 | let info: Element = 196 | match &*self.steam_sso.lock().unwrap() { 197 | SSOStatus::Waiting { url } => button(text("Login")) 198 | .on_press(Message::OpenLink(url.to_string())) 199 | .into(), 200 | _ => text("Waiting...").into(), 201 | }; 202 | 203 | let info = container(info).padding(20); 204 | column!(title, info) 205 | } 206 | LoginType::Google => { 207 | let title: widget::text::Text<'_, Theme, Renderer> = 208 | text("Google").size(20); 209 | 210 | let info: Element = 211 | match &*self.google_sso.lock().unwrap() { 212 | SSOStatus::Waiting { url } => button(text("Login")) 213 | .on_press(Message::OpenLink(url.to_string())) 214 | .into(), 215 | _ => text("Waiting...").into(), 216 | }; 217 | 218 | let info = container(info).padding(20); 219 | column!(title, info) 220 | } 221 | LoginType::Saved => { 222 | let title: widget::text::Text<'_, Theme, Renderer> = 223 | text("Accounts").size(20); 224 | 225 | let mut accounts_col = 226 | column!().spacing(10).width(Length::Fill).padding(20); 227 | 228 | for acc in accounts { 229 | match &acc { 230 | AccountConfig::Regular { name, server, .. } => { 231 | let login_msg = Message::Login { 232 | account: acc.clone(), 233 | auto_login: false, 234 | }; 235 | 236 | // TODO: Make sure they can not login twice 237 | 238 | let server_ident = get_server_code(server); 239 | 240 | let button = button( 241 | row!( 242 | text( 243 | titlecase::titlecase(name).to_string() 244 | ), 245 | horizontal_space(), 246 | text(server_ident) 247 | ) 248 | .width(Length::Fill), 249 | ) 250 | .on_press(login_msg) 251 | .width(Length::Fill); 252 | accounts_col = accounts_col.push(button); 253 | } 254 | AccountConfig::SF { name, .. } => { 255 | let login_msg = Message::Login { 256 | account: acc.clone(), 257 | auto_login: false, 258 | }; 259 | 260 | let button = button( 261 | row!( 262 | text( 263 | titlecase::titlecase(name).to_string() 264 | ), 265 | horizontal_space(), 266 | text("SF") 267 | ) 268 | .width(Length::Fill), 269 | ) 270 | .on_press(login_msg) 271 | .style(theme::Button::Positive) 272 | .width(Length::Fill); 273 | accounts_col = accounts_col.push(button); 274 | } 275 | }; 276 | } 277 | 278 | let scroll = widget::scrollable(accounts_col); 279 | 280 | column!(title, scroll) 281 | } 282 | LoginType::SSOAccounts => { 283 | let title: widget::text::Text<'_, Theme, Renderer> = 284 | text("SSO Accounts").size(20); 285 | 286 | let mut col = column!() 287 | .padding(20) 288 | .spacing(10) 289 | .width(Length::Fixed(400.0)) 290 | .align_items(Alignment::Center); 291 | 292 | for active in &self.active_sso { 293 | let button = button( 294 | row!( 295 | text( 296 | titlecase::titlecase(match &active.ident { 297 | SSOIdent::SF(name) 298 | | SSOIdent::Google(name) 299 | | SSOIdent::Steam(name) => name.as_str(), 300 | }) 301 | .to_string() 302 | ), 303 | horizontal_space(), 304 | text(match active.status { 305 | SSOLoginStatus::Loading => "Loading..", 306 | SSOLoginStatus::Success => "", 307 | }) 308 | ) 309 | .width(Length::Fill), 310 | ) 311 | .width(Length::Fill) 312 | .style(match active.status { 313 | SSOLoginStatus::Loading => theme::Button::Secondary, 314 | SSOLoginStatus::Success => theme::Button::Positive, 315 | }) 316 | .on_press_maybe(match active.status { 317 | SSOLoginStatus::Loading => None, 318 | SSOLoginStatus::Success => { 319 | Some(Message::LoginViewChanged(LoginType::SSOChars)) 320 | } 321 | }); 322 | 323 | col = col.push(button); 324 | } 325 | column!(title, widget::scrollable(col)) 326 | } 327 | LoginType::SSOChars => { 328 | let title: widget::text::Text<'_, Theme, Renderer> = 329 | text("SSO Characters").size(20); 330 | 331 | let mut col = column!() 332 | .padding(20) 333 | .spacing(10) 334 | .width(Length::Fixed(400.0)) 335 | .align_items(Alignment::Center); 336 | 337 | for (pos, active) in self.import_que.iter().enumerate() { 338 | let ident = get_server_code(active.server_url().as_str()); 339 | 340 | let button = button( 341 | row!( 342 | text( 343 | titlecase::titlecase(active.username()) 344 | .to_string() 345 | ), 346 | horizontal_space(), 347 | text(ident) 348 | ) 349 | .width(Length::Fill), 350 | ) 351 | .width(Length::Fill) 352 | .on_press(Message::SSOImport { pos }); 353 | 354 | col = col.push(button); 355 | } 356 | column!(title, widget::scrollable(col)) 357 | } 358 | }; 359 | 360 | let col = current_login 361 | .padding(20) 362 | .spacing(10) 363 | .width(Length::Fixed(400.0)) 364 | .height(Length::Fill) 365 | .align_items(Alignment::Center); 366 | 367 | let col_container = container(col).center_y(); 368 | 369 | column!(top_bar, col_container) 370 | .height(Length::Fill) 371 | .align_items(Alignment::Center) 372 | .spacing(50) 373 | .into() 374 | } 375 | } 376 | 377 | #[derive(Debug, Clone, PartialEq, Eq, Copy)] 378 | pub enum LoginType { 379 | Regular, 380 | SFAccount, 381 | Steam, 382 | Google, 383 | Saved, 384 | SSOAccounts, 385 | SSOChars, 386 | } 387 | 388 | pub struct SSOValidator { 389 | pub status: Arc>, 390 | pub provider: SSOProvider, 391 | } 392 | 393 | impl SSOValidator { 394 | pub async fn check( 395 | &self, 396 | ) -> Result>, String)>, SFError> { 397 | sleep(Duration::from_millis(fastrand::u64(500..=1000))).await; 398 | let mut auth = SSOAuth::new(self.provider).await?; 399 | { 400 | *self.status.lock().unwrap() = SSOStatus::Waiting { 401 | url: auth.auth_url().to_string(), 402 | } 403 | } 404 | 405 | for _ in 0..50 { 406 | let resp = auth.try_login().await?; 407 | match resp { 408 | sf_api::sso::AuthResponse::Success(res) => { 409 | println!("Success"); 410 | let name = res.username().to_string(); 411 | let chars = res.characters().await?; 412 | return Ok(Some((chars, name))); 413 | } 414 | sf_api::sso::AuthResponse::NoAuth(res) => { 415 | auth = res; 416 | } 417 | } 418 | sleep(Duration::from_secs(6)).await; 419 | } 420 | { 421 | *self.status.lock().unwrap() = SSOStatus::Initializing 422 | } 423 | Ok(None) 424 | } 425 | } 426 | 427 | impl Helper { 428 | pub fn login_regular( 429 | &mut self, 430 | name: String, 431 | server: String, 432 | pw_hash: PWHash, 433 | remember: bool, 434 | auto_login: bool, 435 | ) -> Command { 436 | let name = name.trim().to_string(); 437 | let server = server.trim().to_string(); 438 | 439 | let Some(con) = ServerConnection::new(&server) else { 440 | self.login_state.error = Some("Invalid Server URL".to_string()); 441 | return Command::none(); 442 | }; 443 | 444 | let session = 445 | sf_api::session::Session::new_hashed(&name, pw_hash.clone(), con); 446 | 447 | self.login(session, remember, PlayerAuth::Normal(pw_hash), auto_login) 448 | } 449 | 450 | pub fn login( 451 | &mut self, 452 | mut session: sf_api::session::Session, 453 | remember: bool, 454 | auth: PlayerAuth, 455 | auto_login: bool, 456 | ) -> Command { 457 | let server_ident = ServerIdent::new(session.server_url().as_str()); 458 | let Some(connection) = ServerConnection::new(&server_ident.url) else { 459 | self.login_state.error = 460 | Some("Server Url is not valid".to_string()); 461 | return Command::none(); 462 | }; 463 | let name: String = session 464 | .username() 465 | .chars() 466 | .map(|a| a.to_ascii_lowercase()) 467 | .collect(); 468 | 469 | let account_id = AccountID::new(); 470 | let account_ident = AccountIdent { 471 | server_id: server_ident.id, 472 | account: account_id, 473 | }; 474 | let info = AccountInfo::new(&name, auth, account_ident); 475 | let server = self 476 | .servers 477 | .get_or_insert_default(server_ident, connection, None); 478 | 479 | if let Some((_, existing)) = 480 | server.accounts.iter().find(|(_, a)| a.name == name) 481 | { 482 | self.current_view = View::Account { 483 | ident: existing.ident, 484 | page: AccountPage::Scrapbook, 485 | }; 486 | return Command::none(); 487 | } 488 | if !auto_login { 489 | self.current_view = View::Account { 490 | ident: info.ident, 491 | page: AccountPage::Scrapbook, 492 | }; 493 | } 494 | server.accounts.insert(info.ident.account, info); 495 | static WAITING: AtomicU64 = AtomicU64::new(0); 496 | 497 | Command::perform( 498 | async move { 499 | // This likely has some logic issues 500 | let w = 501 | WAITING.fetch_add(1, std::sync::atomic::Ordering::SeqCst); 502 | if w > 0 { 503 | sleep(Duration::from_secs(w)).await; 504 | } 505 | let resp = session.login().await.inspect(|_| { 506 | WAITING.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); 507 | })?; 508 | let gs = GameState::new(resp)?; 509 | let gs = Box::new(gs); 510 | Ok((gs, Box::new(session))) 511 | }, 512 | move |a: Result<_, SFError>| match a { 513 | Ok((gs, session)) => Message::LoggininSuccess { 514 | ident: account_ident, 515 | gs, 516 | session, 517 | remember, 518 | }, 519 | Err(err) => Message::LoggininFailure { 520 | ident: account_ident, 521 | error: err.to_string(), 522 | }, 523 | }, 524 | ) 525 | } 526 | 527 | pub fn login_sf_acc( 528 | &mut self, 529 | name: String, 530 | pwhash: PWHash, 531 | remember_sf: bool, 532 | auto_login: bool, 533 | ) -> Command { 534 | let ident = SSOIdent::SF(name.clone()); 535 | self.login_state.login_typ = LoginType::SSOAccounts; 536 | if self 537 | .login_state 538 | .active_sso 539 | .iter() 540 | .any(|a| matches!(&a.ident, SSOIdent::SF(s) if s.as_str() == name.as_str())) 541 | { 542 | return Command::none(); 543 | } 544 | self.login_state.active_sso.push(SSOLogin { 545 | ident: ident.clone(), 546 | status: SSOLoginStatus::Loading, 547 | }); 548 | 549 | let n2 = name.clone(); 550 | let p2 = pwhash.clone(); 551 | Command::perform( 552 | async move { 553 | let account = SFAccount::login_hashed(n2, p2).await?; 554 | account.characters().await.into_iter().flatten().collect() 555 | }, 556 | move |res| match res { 557 | Ok(chars) => Message::SSOLoginSuccess { 558 | name, 559 | pass: pwhash, 560 | chars, 561 | remember: remember_sf, 562 | auto_login, 563 | }, 564 | Err(error) => Message::SSOLoginFailure { 565 | name, 566 | error: error.to_string(), 567 | }, 568 | }, 569 | ) 570 | } 571 | } 572 | 573 | #[allow(clippy::upper_case_acronyms)] 574 | pub enum PlayerAuth { 575 | Normal(PWHash), 576 | SSO, 577 | } 578 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use chrono::{DateTime, Local}; 4 | use iced::{ 5 | Alignment, Element, Length, 6 | alignment::Horizontal, 7 | theme, 8 | widget::{ 9 | self, Button, button, checkbox, column, container, horizontal_space, 10 | pick_list, progress_bar, row, text, 11 | }, 12 | }; 13 | use iced_aw::{number_input, widgets::DropDown}; 14 | use num_format::ToFormattedString; 15 | use options::view_options; 16 | 17 | use self::{scrapbook::view_scrapbook, underworld::view_underworld}; 18 | use crate::{ 19 | AccountIdent, AccountPage, ActionSelection, Helper, View, 20 | config::{AvailableTheme, Config}, 21 | crawler::CrawlingOrder, 22 | get_server_code, 23 | message::Message, 24 | player::{AccountInfo, AccountStatus}, 25 | server::{CrawlingStatus, ServerInfo}, 26 | top_bar, 27 | }; 28 | 29 | mod options; 30 | mod scrapbook; 31 | pub mod underworld; 32 | 33 | impl Helper { 34 | pub fn view_current_page(&self) -> Element<'_, Message> { 35 | let view: Element = match &self.current_view { 36 | View::Account { ident, page } => self.view_account(*ident, *page), 37 | View::Login => self 38 | .login_state 39 | .view(&self.config.accounts, self.has_accounts()), 40 | View::Overview { selected, action } => { 41 | self.view_overview(selected, action) 42 | } 43 | View::Settings => self.view_settings(), 44 | }; 45 | let main_part = container(view).width(Length::Fill).center_x(); 46 | let mut res = column!(); 47 | 48 | if self.should_update { 49 | let dl_button = button("Download").on_press( 50 | Message::OpenLink("https://github.com/the-marenga/sf-scrapbook-helper/releases/latest".to_string()) 51 | ); 52 | 53 | let ignore_button = button("Ignore") 54 | .on_press(Message::UpdateResult(false)) 55 | .style(theme::Button::Destructive); 56 | 57 | let update_msg = row!( 58 | horizontal_space(), 59 | text("A new Version is available!").size(20), 60 | dl_button, 61 | horizontal_space(), 62 | ignore_button, 63 | ) 64 | .align_items(Alignment::Center) 65 | .spacing(10) 66 | .width(Length::Fill) 67 | .padding(15); 68 | 69 | res = res.push(update_msg); 70 | } 71 | res.push(main_part).into() 72 | } 73 | 74 | fn view_account( 75 | &self, 76 | ident: AccountIdent, 77 | page: AccountPage, 78 | ) -> Element<'_, Message> { 79 | let Some((server, player)) = self.servers.get_ident(&ident) else { 80 | return self 81 | .login_state 82 | .view(&self.config.accounts, self.has_accounts()); 83 | }; 84 | 85 | let selection = |this_page: AccountPage| -> Element { 86 | button(text(format!("{this_page:?}"))) 87 | .on_press(Message::ViewSubPage { 88 | player: player.ident, 89 | page: this_page, 90 | }) 91 | .padding(4) 92 | .style(if this_page == page { 93 | theme::Button::Primary 94 | } else { 95 | theme::Button::Secondary 96 | }) 97 | .into() 98 | }; 99 | 100 | let top = row!( 101 | text(titlecase::titlecase(&player.name).to_string()).size(20), 102 | text(get_server_code(&server.ident.url)) 103 | .horizontal_alignment(iced::alignment::Horizontal::Right) 104 | .size(20), 105 | selection(AccountPage::Scrapbook), 106 | selection(AccountPage::Underworld), 107 | selection(AccountPage::Options), 108 | button(text("Logout")) 109 | .on_press(Message::RemoveAccount { 110 | ident: player.ident, 111 | }) 112 | .padding(4) 113 | .style(theme::Button::Destructive) 114 | ) 115 | .spacing(15) 116 | .align_items(Alignment::Center); 117 | 118 | let top_bar = top_bar(top.into(), Some(Message::ViewOverview)); 119 | 120 | let middle = match page { 121 | AccountPage::Scrapbook => { 122 | view_scrapbook(server, player, &self.config, &self.class_images) 123 | } 124 | AccountPage::Underworld => view_underworld( 125 | server, player, &self.config, &self.class_images, 126 | ), 127 | AccountPage::Options => view_options(player, server, &self.config), 128 | }; 129 | 130 | let col_container = container(middle).center_y(); 131 | 132 | column!(top_bar, col_container) 133 | .spacing(5) 134 | .height(Length::Fill) 135 | .align_items(Alignment::Center) 136 | .into() 137 | } 138 | 139 | fn view_settings(&self) -> Element<'_, Message> { 140 | let top_row = top_bar( 141 | text("Settings").size(20).into(), 142 | if self.has_accounts() { 143 | Some(Message::ViewOverview) 144 | } else { 145 | Some(Message::ViewLogin) 146 | }, 147 | ); 148 | use AvailableTheme::*; 149 | let all_themes = [ 150 | Light, Dark, Dracula, Nord, SolarizedLight, SolarizedDark, 151 | GruvboxLight, GruvboxDark, CatppuccinLatte, CatppuccinFrappe, 152 | CatppuccinMacchiato, CatppuccinMocha, TokyoNight, TokyoNightStorm, 153 | TokyoNightLight, KanagawaWave, KanagawaDragon, KanagawaLotus, 154 | Moonfly, Nightfly, Oxocarbon, 155 | ]; 156 | 157 | let theme_picker = pick_list( 158 | all_themes, 159 | Some(self.config.theme), 160 | Message::ChangeTheme, 161 | ) 162 | .width(Length::Fixed(200.0)); 163 | 164 | let theme_row = 165 | row!(text("Theme: ").width(Length::Fixed(100.0)), theme_picker) 166 | .width(Length::Fill) 167 | .align_items(Alignment::Center); 168 | 169 | let auto_fetch_hof = checkbox( 170 | "Fetch online HoF backup during login", 171 | self.config.auto_fetch_newest, 172 | ) 173 | .on_toggle(Message::SetAutoFetch); 174 | 175 | let auto_poll = 176 | checkbox("Keep characters logged in", self.config.auto_poll) 177 | .on_toggle(Message::SetAutoPoll); 178 | 179 | let crawling_restrict = checkbox( 180 | "Show advanced crawling options", 181 | self.config.show_crawling_restrict, 182 | ) 183 | .on_toggle(Message::AdvancedLevelRestrict); 184 | 185 | let show_class_icons = 186 | checkbox("Show class icons", self.config.show_class_icons) 187 | .on_toggle(Message::ShowClasses); 188 | 189 | let max_threads = 190 | number_input(self.config.max_threads, 50, Message::SetMaxThreads); 191 | 192 | let max_threads = row!("Max threads:", horizontal_space(), max_threads) 193 | .width(Length::Fill) 194 | .align_items(Alignment::Center); 195 | 196 | let start_threads = number_input( 197 | self.config.start_threads, 198 | 50.min(self.config.max_threads), 199 | Message::SetStartThreads, 200 | ); 201 | 202 | let start_threads = 203 | row!("Starting threads:", horizontal_space(), start_threads) 204 | .width(Length::Fill) 205 | .align_items(Alignment::Center); 206 | 207 | let blacklist_threshold = number_input( 208 | self.config.blacklist_threshold, 209 | 10, 210 | Message::SetBlacklistThr, 211 | ); 212 | 213 | let blacklist_threshold = row!( 214 | "Blacklist threshhold:", 215 | horizontal_space(), 216 | blacklist_threshold 217 | ) 218 | .width(Length::Fill) 219 | .align_items(Alignment::Center); 220 | 221 | let settings_column = column!( 222 | theme_row, auto_fetch_hof, auto_poll, max_threads, start_threads, 223 | blacklist_threshold, crawling_restrict, show_class_icons 224 | ) 225 | .width(Length::Fixed(300.0)) 226 | .spacing(20); 227 | 228 | column!(top_row, settings_column) 229 | .spacing(20) 230 | .height(Length::Fill) 231 | .width(Length::Fill) 232 | .align_items(Alignment::Center) 233 | .into() 234 | } 235 | 236 | fn view_overview( 237 | &self, 238 | selected: &HashSet, 239 | currrent_action: &Option, 240 | ) -> Element<'_, Message> { 241 | let top_bar = 242 | top_bar(text("Overview").size(20).into(), Some(Message::ViewLogin)); 243 | 244 | let mut accounts = column!() 245 | .padding(20) 246 | .spacing(5) 247 | .width(Length::Fill) 248 | .align_items(Alignment::Center); 249 | 250 | let info_row = row!( 251 | center(text("Status").width(ACC_STATUS_WIDTH)), 252 | center(text("Server").width(SERVER_CODE_WIDTH)), 253 | text("Name").width(ACC_NAME_WIDTH), 254 | horizontal_space(), 255 | center(text("Underworld").width(UNDERWORLD_WIDTH)), 256 | center(text("Arena").width(NEXT_FIGHT_WIDTH)), 257 | center(text("Scrapbook").width(SCRAPBOOK_COUNT_WIDTH)), 258 | text("Crawling").width(CRAWLING_STATUS_WIDTH), 259 | ) 260 | .spacing(10.0) 261 | .width(Length::Fill) 262 | .padding(5.0); 263 | 264 | let all_active: Vec<_> = self 265 | .servers 266 | .0 267 | .values() 268 | .flat_map(|a| a.accounts.values()) 269 | .map(|a| a.ident) 270 | .collect(); 271 | 272 | let cb = checkbox("", all_active.iter().all(|a| selected.contains(a))) 273 | .on_toggle(move |nv| Message::SetOverviewSelected { 274 | ident: all_active.clone(), 275 | val: nv, 276 | }) 277 | .size(13.0); 278 | 279 | let this_action = Some(ActionSelection::Multi); 280 | let is_acting = currrent_action == &this_action; 281 | 282 | let mut action_button = button( 283 | iced_aw::core::icons::bootstrap::icon_to_text( 284 | iced_aw::Bootstrap::ThreeDotsVertical, 285 | ) 286 | .size(18.0), 287 | ) 288 | .padding(4.0); 289 | 290 | if is_acting { 291 | action_button = action_button.on_press(Message::SetAction(None)) 292 | } else if !selected.is_empty() { 293 | action_button = 294 | action_button.on_press(Message::SetAction(this_action)) 295 | } 296 | 297 | let action_dd = 298 | DropDown::new(action_button, self.overview_actions(), is_acting) 299 | .width(Length::Fill) 300 | .on_dismiss(Message::SetAction(None)) 301 | .alignment(iced_aw::drop_down::Alignment::BottomStart); 302 | 303 | let full_row = 304 | row!(cb, info_row, action_dd).align_items(Alignment::Center); 305 | 306 | accounts = accounts.push(full_row); 307 | 308 | let mut servers: Vec<_> = self.servers.0.values().collect(); 309 | servers.sort_by_key(|a| &a.ident.ident); 310 | for server in servers { 311 | let server_status: Box = match &server.crawling { 312 | CrawlingStatus::Waiting => "Waiting".into(), 313 | CrawlingStatus::Restoring => "Restoring".into(), 314 | CrawlingStatus::CrawlingFailed(_) => "Error".into(), 315 | CrawlingStatus::Crawling { que, .. } => { 316 | let lock = que.lock().unwrap(); 317 | let remaining = lock.count_remaining(); 318 | drop(lock); 319 | if remaining == 0 { 320 | "Finished".into() 321 | } else { 322 | remaining 323 | .to_formatted_string(&self.config.num_format) 324 | .into() 325 | } 326 | } 327 | }; 328 | 329 | let mut accs: Vec<_> = server.accounts.values().collect(); 330 | accs.sort_by_key(|a| &a.name); 331 | for acc in accs { 332 | let info_row = 333 | overview_row(acc, server, &server_status, &self.config); 334 | let selected = selected.contains(&acc.ident); 335 | 336 | let ident = acc.ident; 337 | 338 | let cb = checkbox("", selected) 339 | .on_toggle(move |nv| Message::SetOverviewSelected { 340 | ident: vec![ident], 341 | val: nv, 342 | }) 343 | .size(13.0); 344 | 345 | let this_action = Some(ActionSelection::Character(ident)); 346 | let is_acting = currrent_action == &this_action; 347 | 348 | let action_button = button( 349 | iced_aw::core::icons::bootstrap::icon_to_text( 350 | iced_aw::Bootstrap::ThreeDotsVertical, 351 | ) 352 | .size(18.0), 353 | ) 354 | .on_press(if is_acting { 355 | Message::SetAction(None) 356 | } else { 357 | Message::SetAction(this_action) 358 | }) 359 | .padding(4.0); 360 | 361 | let action_dd = DropDown::new( 362 | action_button, 363 | self.overview_actions(), 364 | is_acting, 365 | ) 366 | .width(Length::Fill) 367 | .on_dismiss(Message::SetAction(None)) 368 | .alignment(iced_aw::drop_down::Alignment::BottomStart); 369 | 370 | let full_row = row!(cb, info_row, action_dd) 371 | .spacing(5.0) 372 | .align_items(Alignment::Center); 373 | 374 | accounts = accounts.push(full_row); 375 | } 376 | } 377 | 378 | column!(top_bar, widget::scrollable(accounts)) 379 | .spacing(5) 380 | .height(Length::Fill) 381 | .width(Length::Fill) 382 | .align_items(Alignment::Center) 383 | .into() 384 | } 385 | fn overview_actions(&self) -> Element<'_, Message> { 386 | let mut all_actions = column!().spacing(4.0); 387 | 388 | fn action(button: Button) -> Button { 389 | button.width(100.0) 390 | } 391 | 392 | all_actions = all_actions.push(action( 393 | button(row!( 394 | text("Auto Battle"), 395 | horizontal_space(), 396 | iced_aw::core::icons::bootstrap::icon_to_text( 397 | iced_aw::Bootstrap::Check, 398 | ) 399 | )) 400 | .on_press(Message::MultiAction { 401 | action: OverviewAction::AutoBattle(true), 402 | }), 403 | )); 404 | 405 | all_actions = all_actions.push(action( 406 | button(row!( 407 | text("Auto Battle"), 408 | horizontal_space(), 409 | iced_aw::core::icons::bootstrap::icon_to_text( 410 | iced_aw::Bootstrap::X, 411 | ) 412 | )) 413 | .on_press(Message::MultiAction { 414 | action: OverviewAction::AutoBattle(false), 415 | }), 416 | )); 417 | 418 | all_actions = all_actions.push(action( 419 | button("Logout") 420 | .on_press(Message::MultiAction { 421 | action: OverviewAction::Logout, 422 | }) 423 | .style(theme::Button::Destructive), 424 | )); 425 | 426 | all_actions.into() 427 | } 428 | } 429 | 430 | #[derive(Debug, Clone, Copy)] 431 | pub enum OverviewAction { 432 | Logout, 433 | AutoBattle(bool), 434 | } 435 | 436 | const ACC_STATUS_WIDTH: f32 = 80.0; 437 | const ACC_NAME_WIDTH: f32 = 200.0; 438 | const SERVER_CODE_WIDTH: f32 = 50.0; 439 | const SCRAPBOOK_COUNT_WIDTH: f32 = 60.0; 440 | const NEXT_FIGHT_WIDTH: f32 = 60.0; 441 | const UNDERWORLD_WIDTH: f32 = 60.0; 442 | const CRAWLING_STATUS_WIDTH: f32 = 80.0; 443 | 444 | fn overview_row<'a>( 445 | acc: &'a AccountInfo, 446 | server: &'a ServerInfo, 447 | crawling_status: &'_ str, 448 | config: &'a Config, 449 | ) -> Element<'a, Message> { 450 | let status_text = |t: &str| center(text(t).width(ACC_STATUS_WIDTH)); 451 | 452 | let mut next_free_fight = None; 453 | 454 | let acc_status = match &*acc.status.lock().unwrap() { 455 | AccountStatus::LoggingIn => status_text("Logging in"), 456 | AccountStatus::Idle(_, gs) => { 457 | next_free_fight = Some(gs.arena.next_free_fight); 458 | status_text("Active") 459 | } 460 | AccountStatus::Busy(gs, reason) => { 461 | next_free_fight = Some(gs.arena.next_free_fight); 462 | status_text(reason) 463 | } 464 | AccountStatus::FatalError(_) => status_text("Error!"), 465 | AccountStatus::LoggingInAgain => status_text("Logging in"), 466 | }; 467 | 468 | let server_code = center( 469 | text(get_server_code(&server.ident.url)).width(SERVER_CODE_WIDTH), 470 | ); 471 | 472 | let acc_name = text(titlecase::titlecase(acc.name.as_str()).to_string()) 473 | .width(ACC_NAME_WIDTH); 474 | 475 | let scrapbook_count: String = match &acc.scrapbook_info { 476 | Some(si) => si 477 | .scrapbook 478 | .items 479 | .len() 480 | .to_formatted_string(&config.num_format), 481 | None => "".into(), 482 | }; 483 | let scrapbook_count = text(scrapbook_count) 484 | .width(SCRAPBOOK_COUNT_WIDTH) 485 | .horizontal_alignment(Horizontal::Center); 486 | 487 | let icon_to_text = 488 | |icon| iced_aw::core::icons::bootstrap::icon_to_text(icon).size(18.0); 489 | 490 | let abs = acc 491 | .scrapbook_info 492 | .as_ref() 493 | .map(|a| { 494 | if a.auto_battle { 495 | iced_aw::Bootstrap::LightningFill 496 | } else { 497 | iced_aw::Bootstrap::Lightning 498 | } 499 | }) 500 | .unwrap_or(iced_aw::Bootstrap::Question); 501 | 502 | let next_free_fight = match next_free_fight { 503 | None => icon_to_text(iced_aw::Bootstrap::Question), 504 | Some(Some(x)) if x >= Local::now() => text(remaining_minutes(x)), 505 | Some(_) => icon_to_text(iced_aw::Bootstrap::Check), 506 | }; 507 | 508 | let next_free_fight = row!( 509 | center(next_free_fight.width(25.0)), 510 | center(icon_to_text(abs)) 511 | ) 512 | .align_items(Alignment::Center) 513 | .spacing(4.0); 514 | 515 | let next_free_fight = column!(next_free_fight) 516 | .align_items(Alignment::Center) 517 | .width(NEXT_FIGHT_WIDTH); 518 | 519 | let underworld_info: Element = acc 520 | .underworld_info 521 | .as_ref() 522 | .map(|a| { 523 | let auto_status = if a.auto_lure { 524 | iced_aw::Bootstrap::LightningFill 525 | } else { 526 | iced_aw::Bootstrap::Lightning 527 | }; 528 | 529 | let remaining = 5u16.saturating_sub(a.underworld.lured_today); 530 | let remaining = if remaining == 0 { 531 | icon_to_text(iced_aw::Bootstrap::Check) 532 | } else { 533 | text(remaining.to_string()) 534 | }; 535 | 536 | let row = row!( 537 | center(remaining.width(25.0)), 538 | center(icon_to_text(auto_status)) 539 | ) 540 | .align_items(Alignment::Center) 541 | .spacing(4.0); 542 | 543 | column!(row) 544 | .width(UNDERWORLD_WIDTH) 545 | .align_items(Alignment::Center) 546 | .into() 547 | }) 548 | .unwrap_or( 549 | center(icon_to_text(iced_aw::Bootstrap::X)) 550 | .width(UNDERWORLD_WIDTH) 551 | .into(), 552 | ); 553 | 554 | let crawling_status = text(crawling_status).width(CRAWLING_STATUS_WIDTH); 555 | 556 | let info_row = row!( 557 | acc_status, 558 | server_code, 559 | acc_name, 560 | horizontal_space(), 561 | underworld_info, 562 | next_free_fight, 563 | scrapbook_count, 564 | crawling_status 565 | ) 566 | .spacing(10.0) 567 | .align_items(Alignment::Center); 568 | 569 | button(info_row) 570 | .on_press(Message::ShowPlayer { ident: acc.ident }) 571 | .width(Length::Fill) 572 | .height(Length::Shrink) 573 | .padding(4.0) 574 | .style(theme::Button::Secondary) 575 | .into() 576 | } 577 | 578 | fn remaining_minutes(time: DateTime) -> String { 579 | let now = Local::now(); 580 | let secs = (time - now).num_seconds() % 60; 581 | let mins = (time - now).num_seconds() / 60; 582 | format!("{mins}:{secs:02}") 583 | } 584 | 585 | fn center(t: text::Text) -> text::Text { 586 | t.horizontal_alignment(Horizontal::Center) 587 | } 588 | 589 | pub fn view_crawling<'a>( 590 | server: &'a ServerInfo, 591 | config: &'a Config, 592 | ) -> Element<'a, Message> { 593 | let mut left_col = column!().align_items(Alignment::Center).spacing(10); 594 | 595 | let sid = server.ident.id; 596 | 597 | match &server.crawling { 598 | CrawlingStatus::Crawling { 599 | threads, 600 | que, 601 | player_info, 602 | .. 603 | } => { 604 | let lock = que.lock().unwrap(); 605 | let remaining = lock.count_remaining(); 606 | let crawled = player_info.len(); 607 | let total = remaining + crawled; 608 | 609 | let progress_text = text(format!( 610 | "Fetched {}/{}", 611 | crawled.to_formatted_string(&config.num_format), 612 | total.to_formatted_string(&config.num_format) 613 | )); 614 | left_col = left_col.push(progress_text); 615 | 616 | let progress = progress_bar(0.0..=total as f32, crawled as f32) 617 | .height(Length::Fixed(10.0)); 618 | left_col = left_col.push(progress); 619 | 620 | let thread_num = 621 | number_input(*threads, config.max_threads, move |nv| { 622 | Message::CrawlerSetThreads { 623 | server: sid, 624 | new_count: nv, 625 | } 626 | }); 627 | let thread_num = 628 | row!(text("Threads: "), horizontal_space(), thread_num) 629 | .align_items(Alignment::Center); 630 | left_col = left_col.push(thread_num); 631 | let order_picker = pick_list( 632 | [ 633 | CrawlingOrder::Random, 634 | CrawlingOrder::TopDown, 635 | CrawlingOrder::BottomUp, 636 | ], 637 | Some(lock.order), 638 | |nv| Message::OrderChange { 639 | server: server.ident.id, 640 | new: nv, 641 | }, 642 | ); 643 | left_col = left_col.push( 644 | row!( 645 | text("Crawling Order:").width(Length::FillPortion(1)), 646 | order_picker.width(Length::FillPortion(1)) 647 | ) 648 | .align_items(Alignment::Center), 649 | ); 650 | 651 | if config.show_crawling_restrict 652 | || !lock.lvl_skipped_accounts.is_empty() 653 | { 654 | let old_max = lock.max_level; 655 | let old_min = lock.min_level; 656 | 657 | let set_min_lvl = 658 | number_input(lock.min_level, 9999u32, move |nv| { 659 | Message::CrawlerSetMinMax { 660 | server: sid, 661 | min: nv, 662 | max: old_max, 663 | } 664 | }); 665 | let thread_num = 666 | row!(text("Min Lvl: "), horizontal_space(), set_min_lvl) 667 | .align_items(Alignment::Center); 668 | left_col = left_col.push(thread_num); 669 | 670 | let set_min_lvl = 671 | number_input(lock.max_level, 9999u32, move |nv| { 672 | Message::CrawlerSetMinMax { 673 | server: sid, 674 | min: old_min, 675 | max: nv, 676 | } 677 | }); 678 | let thread_num = 679 | row!(text("Max Lvl: "), horizontal_space(), set_min_lvl) 680 | .align_items(Alignment::Center); 681 | left_col = left_col.push(thread_num); 682 | } 683 | 684 | let clear = button("Clear HoF").on_press(Message::ClearHof(sid)); 685 | let save = button("Save HoF").on_press(Message::SaveHoF(sid)); 686 | left_col = left_col.push( 687 | column!(row!(clear, save).spacing(10)) 688 | .align_items(Alignment::Center), 689 | ); 690 | 691 | drop(lock); 692 | } 693 | CrawlingStatus::Waiting => { 694 | left_col = left_col.push(text("Waiting for Player...")); 695 | } 696 | CrawlingStatus::Restoring => { 697 | left_col = left_col.push(text("Loading Server Data...")); 698 | } 699 | CrawlingStatus::CrawlingFailed(reason) => { 700 | // TODO: Maybe display this? 701 | _ = reason; 702 | left_col = left_col.push(text("Crawling Failed")); 703 | } 704 | } 705 | 706 | left_col.into() 707 | } 708 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![windows_subsystem = "windows"] 2 | mod backup; 3 | mod config; 4 | mod crawler; 5 | mod login; 6 | mod message; 7 | mod player; 8 | mod server; 9 | mod ui; 10 | 11 | use std::{ 12 | collections::{BTreeMap, HashMap, HashSet, hash_map::Entry}, 13 | sync::{Arc, Mutex, atomic::AtomicU64}, 14 | time::Duration, 15 | }; 16 | 17 | use chrono::{Local, NaiveDate, Utc}; 18 | use clap::{Parser, Subcommand}; 19 | use config::{AccountConfig, Config}; 20 | use crawler::{CrawlAction, Crawler, CrawlerState, CrawlingOrder, WorkerQue}; 21 | use iced::{ 22 | Alignment, Application, Command, Element, Length, Settings, Subscription, 23 | Theme, executor, subscription, theme, 24 | widget::{button, container, horizontal_space, row, text}, 25 | }; 26 | use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; 27 | use log::{debug, info, trace}; 28 | use log4rs::{ 29 | append::{ 30 | console::{ConsoleAppender, Target}, 31 | file::FileAppender, 32 | }, 33 | config::{Appender, Logger, Root}, 34 | encode::pattern::PatternEncoder, 35 | }; 36 | use login::{LoginState, LoginType, PlayerAuth, SSOStatus, SSOValidator}; 37 | use nohash_hasher::{IntMap, IntSet}; 38 | use player::{ 39 | AccountInfo, AccountStatus, AutoAttackChecker, AutoLureChecker, AutoPoll, 40 | ScrapbookInfo, 41 | }; 42 | use serde::{Deserialize, Serialize}; 43 | use server::{CrawlingStatus, ServerIdent, ServerInfo, Servers}; 44 | use sf_api::{ 45 | gamestate::{character::Class, unlockables::EquipmentIdent}, 46 | session::ServerConnection, 47 | sso::{SSOProvider, ServerLookup}, 48 | }; 49 | use tokio::time::sleep; 50 | 51 | use crate::{ 52 | config::{AccountCreds, AvailableTheme}, 53 | message::Message, 54 | }; 55 | pub const PER_PAGE: usize = 51; 56 | 57 | #[derive(Debug, Parser)] 58 | #[command(version, about, long_about = None)] 59 | struct Args { 60 | #[command(subcommand)] 61 | pub sub: Option, 62 | } 63 | 64 | #[derive(Debug, Subcommand, Clone)] 65 | enum CLICommand { 66 | Crawl { 67 | /// The amount of servers that will be simultaniously crawled 68 | #[arg(short, long, default_value_t = 4, value_parser=concurrency_limits)] 69 | concurrency: usize, 70 | /// The amount of threads per server used to 71 | #[arg(short, long, default_value_t = 1, value_parser=concurrency_limits)] 72 | threads: usize, 73 | #[clap(flatten)] 74 | servers: ServerSelect, 75 | }, 76 | } 77 | fn concurrency_limits(s: &str) -> Result { 78 | clap_num::number_range(s, 1, 50) 79 | } 80 | 81 | #[derive(Debug, clap::Args, Clone)] 82 | #[group(required = true, multiple = false)] 83 | pub struct ServerSelect { 84 | /// Fetches a list of all servers and crawls all of them. Supercedes urls 85 | #[arg(short, long)] 86 | all: bool, 87 | /// The list of all server urls to fetch 88 | #[arg(short, long, value_delimiter = ' ', num_args = 1..)] 89 | urls: Option>, 90 | } 91 | 92 | impl Args { 93 | pub fn is_headless(&self) -> bool { 94 | self.sub.is_some() 95 | } 96 | } 97 | 98 | fn main() -> iced::Result { 99 | let args = Args::parse(); 100 | 101 | let is_headless = args.is_headless(); 102 | let config = get_log_config(is_headless); 103 | log4rs::init_config(config).unwrap(); 104 | info!("Starting up"); 105 | 106 | let mut settings = Settings::with_flags(args); 107 | settings.window.min_size = Some(iced::Size { 108 | width: 700.0, 109 | height: 700.0, 110 | }); 111 | settings.default_text_size = 13.0f32.into(); 112 | settings.window.visible = !is_headless; 113 | 114 | let raw_img = include_bytes!("../assets/icon.ico"); 115 | let img = 116 | image::load_from_memory_with_format(raw_img, image::ImageFormat::Ico) 117 | .ok(); 118 | if let Some(img) = img { 119 | let height = img.height(); 120 | let width = img.width(); 121 | let icon = 122 | iced::window::icon::from_rgba(img.into_bytes(), width, height).ok(); 123 | settings.window.icon = icon; 124 | } 125 | Helper::run(settings) 126 | } 127 | 128 | struct Helper { 129 | servers: Servers, 130 | current_view: View, 131 | login_state: LoginState, 132 | config: Config, 133 | should_update: bool, 134 | class_images: ClassImages, 135 | cli_crawling: Option, 136 | } 137 | 138 | struct CLICrawling { 139 | todo_servers: Vec, 140 | mbp: MultiProgress, 141 | threads: usize, 142 | active: usize, 143 | } 144 | 145 | struct ClassImages { 146 | assassin: iced::widget::image::Handle, 147 | bard: iced::widget::image::Handle, 148 | berserk: iced::widget::image::Handle, 149 | battle_mage: iced::widget::image::Handle, 150 | demon_hunter: iced::widget::image::Handle, 151 | druid: iced::widget::image::Handle, 152 | necromancer: iced::widget::image::Handle, 153 | scout: iced::widget::image::Handle, 154 | warrior: iced::widget::image::Handle, 155 | mage: iced::widget::image::Handle, 156 | paladin: iced::widget::image::Handle, 157 | } 158 | 159 | macro_rules! load_class_image { 160 | ($path:expr) => {{ 161 | let raw_img = include_bytes!($path); 162 | let image = image::load_from_memory_with_format( 163 | raw_img, 164 | image::ImageFormat::WebP, 165 | ) 166 | .unwrap(); 167 | iced::widget::image::Handle::from_pixels( 168 | image.width(), 169 | image.height(), 170 | image.into_bytes(), 171 | ) 172 | }}; 173 | } 174 | 175 | impl ClassImages { 176 | pub fn new() -> ClassImages { 177 | ClassImages { 178 | assassin: load_class_image!("../assets/classes/assassin.webp"), 179 | paladin: load_class_image!("../assets/classes/paladin.webp"), 180 | bard: load_class_image!("../assets/classes/bard.webp"), 181 | berserk: load_class_image!("../assets/classes/berserk.webp"), 182 | demon_hunter: load_class_image!( 183 | "../assets/classes/demon_hunter.webp" 184 | ), 185 | druid: load_class_image!("../assets/classes/druid.webp"), 186 | necromancer: load_class_image!( 187 | "../assets/classes/necromancer.webp" 188 | ), 189 | scout: load_class_image!("../assets/classes/scout.webp"), 190 | warrior: load_class_image!("../assets/classes/warrior.webp"), 191 | mage: load_class_image!("../assets/classes/mage.webp"), 192 | battle_mage: load_class_image!( 193 | "../assets/classes/battle_mage.webp" 194 | ), 195 | } 196 | } 197 | 198 | pub fn get_handle(&self, class: Class) -> iced::widget::image::Handle { 199 | match class { 200 | Class::Warrior => self.warrior.clone(), 201 | Class::Mage => self.mage.clone(), 202 | Class::Scout => self.scout.clone(), 203 | Class::Assassin => self.assassin.clone(), 204 | Class::BattleMage => self.battle_mage.clone(), 205 | Class::Berserker => self.berserk.clone(), 206 | Class::DemonHunter => self.demon_hunter.clone(), 207 | Class::Druid => self.druid.clone(), 208 | Class::Bard => self.bard.clone(), 209 | Class::Necromancer => self.necromancer.clone(), 210 | Class::Paladin => self.paladin.clone(), 211 | // TODO: Give this a custom icon 212 | Class::PlagueDoctor => self.assassin.clone(), 213 | } 214 | } 215 | } 216 | 217 | #[derive(Debug, PartialEq, Eq, Clone)] 218 | enum View { 219 | Account { 220 | ident: AccountIdent, 221 | page: AccountPage, 222 | }, 223 | Overview { 224 | selected: HashSet, 225 | action: Option, 226 | }, 227 | Login, 228 | Settings, 229 | } 230 | 231 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 232 | pub enum ActionSelection { 233 | Multi, 234 | Character(AccountIdent), 235 | } 236 | 237 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 238 | enum AccountPage { 239 | Scrapbook, 240 | Underworld, 241 | Options, 242 | } 243 | 244 | fn get_server_code(server: &str) -> String { 245 | let server = server.trim_start_matches("https:"); 246 | let server = server.trim_start_matches("http:"); 247 | let server = server.replace('/', ""); 248 | let mut parts = server.split('.'); 249 | let a = parts.next(); 250 | _ = parts.next(); 251 | let b = parts.next(); 252 | 253 | match (a, b) { 254 | (Some(a), Some(b)) => { 255 | format!("{a}.{b}") 256 | } 257 | _ => String::new(), 258 | } 259 | } 260 | 261 | #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] 262 | pub struct CharacterInfo { 263 | equipment: Vec, 264 | name: String, 265 | uid: u32, 266 | level: u16, 267 | #[serde(skip)] 268 | stats: Option, 269 | #[serde(skip)] 270 | fetch_date: Option, 271 | #[serde(skip)] 272 | class: Option, 273 | } 274 | 275 | impl CharacterInfo { 276 | pub fn is_old(&self) -> bool { 277 | self.fetch_date.unwrap_or_default() < Utc::now().date_naive() 278 | } 279 | } 280 | 281 | impl PartialOrd for CharacterInfo { 282 | fn partial_cmp(&self, other: &Self) -> Option { 283 | Some(self.cmp(other)) 284 | } 285 | } 286 | 287 | impl Ord for CharacterInfo { 288 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 289 | match self.level.cmp(&other.level) { 290 | core::cmp::Ordering::Equal => {} 291 | ord => return ord.reverse(), 292 | } 293 | match self.name.cmp(&other.name) { 294 | core::cmp::Ordering::Equal => {} 295 | ord => return ord, 296 | } 297 | self.uid.cmp(&other.uid) 298 | } 299 | } 300 | 301 | impl Application for Helper { 302 | type Executor = executor::Default; 303 | 304 | type Message = Message; 305 | 306 | type Theme = Theme; 307 | 308 | type Flags = Args; 309 | 310 | fn new(flags: Args) -> (Self, iced::Command) { 311 | let config = Config::restore().unwrap_or_default(); 312 | let mut helper = Helper { 313 | servers: Default::default(), 314 | login_state: LoginState { 315 | login_typ: if config.accounts.is_empty() { 316 | LoginType::Regular 317 | } else { 318 | LoginType::Saved 319 | }, 320 | name: String::new(), 321 | password: String::new(), 322 | server: "f1.sfgame.net".to_string(), 323 | error: None, 324 | remember_me: true, 325 | active_sso: vec![], 326 | import_que: vec![], 327 | google_sso: Arc::new(Mutex::new(SSOStatus::Initializing)), 328 | steam_sso: Arc::new(Mutex::new(SSOStatus::Initializing)), 329 | }, 330 | current_view: View::Login, 331 | should_update: false, 332 | class_images: ClassImages::new(), 333 | config, 334 | cli_crawling: None, 335 | }; 336 | 337 | let fetch_update = 338 | Command::perform(async { check_update().await }, |res| { 339 | Message::UpdateResult(res.unwrap_or_default()) 340 | }); 341 | let mut commands = vec![fetch_update]; 342 | 343 | if let Some(CLICommand::Crawl { 344 | concurrency, 345 | threads, 346 | servers, 347 | }) = flags.sub 348 | { 349 | let mut info = CLICrawling { 350 | todo_servers: Vec::new(), 351 | mbp: MultiProgress::new(), 352 | active: concurrency, 353 | threads, 354 | }; 355 | 356 | if let Some(servers) = servers.urls { 357 | info.todo_servers = servers; 358 | 359 | for _ in 0..concurrency { 360 | commands.push(Command::perform(async {}, move |_| { 361 | Message::NextCLICrawling 362 | })) 363 | } 364 | } else if servers.all { 365 | let c = Command::perform( 366 | async { 367 | ServerLookup::fetch().await.ok().map(|a| { 368 | a.all() 369 | .into_iter() 370 | .map(|a| a.to_string()) 371 | .filter(|a| a != "https://speed.sfgame.net/") 372 | .collect() 373 | }) 374 | }, 375 | move |servers| Message::CrawlAllRes { 376 | servers, 377 | concurrency, 378 | }, 379 | ); 380 | commands.push(c); 381 | } 382 | helper.cli_crawling = Some(info); 383 | } 384 | commands.push( 385 | iced::font::load(iced_aw::BOOTSTRAP_FONT_BYTES) 386 | .map(Message::FontLoaded), 387 | ); 388 | 389 | let mut loading = 0; 390 | 391 | for acc in &helper.config.accounts { 392 | match acc { 393 | AccountConfig::Regular { config, .. } => { 394 | if config.login { 395 | let acc = acc.clone(); 396 | loading += 1; 397 | commands.push(Command::perform( 398 | async move { 399 | sleep(Duration::from_millis( 400 | (loading - 1) * 200, 401 | )) 402 | .await 403 | }, 404 | move |_| Message::Login { 405 | account: acc, 406 | auto_login: true, 407 | }, 408 | )); 409 | } 410 | } 411 | AccountConfig::SF { characters, .. } => { 412 | if characters.iter().any(|a| a.config.login) { 413 | loading += 1; 414 | let acc = acc.clone(); 415 | commands.push(Command::perform( 416 | async move { 417 | sleep(Duration::from_millis( 418 | (loading - 1) * 200, 419 | )) 420 | .await 421 | }, 422 | move |_| Message::Login { 423 | account: acc, 424 | auto_login: true, 425 | }, 426 | )); 427 | } 428 | } 429 | } 430 | } 431 | 432 | if loading > 0 { 433 | helper.current_view = View::Overview { 434 | selected: Default::default(), 435 | action: Default::default(), 436 | }; 437 | } 438 | 439 | (helper, Command::batch(commands)) 440 | } 441 | 442 | fn theme(&self) -> Theme { 443 | self.config.theme.theme() 444 | } 445 | 446 | fn title(&self) -> String { 447 | format!("Scrapbook Helper v{}", env!("CARGO_PKG_VERSION")) 448 | } 449 | 450 | fn update( 451 | &mut self, 452 | message: Self::Message, 453 | ) -> iced::Command { 454 | // let start = std::time::Instant::now(); 455 | // let msg = format!("{message:?}"); 456 | let res = self.handle_msg(message); 457 | _ = &res; 458 | // let time = start.elapsed(); 459 | // if true{ 460 | // println!( 461 | // "{} took: {time:?}", 462 | // msg.split('{').next().unwrap_or(&msg).trim(), 463 | // ); 464 | // } 465 | res 466 | } 467 | 468 | fn view( 469 | &self, 470 | ) -> iced::Element<'_, Self::Message, Self::Theme, iced::Renderer> { 471 | self.view_current_page() 472 | } 473 | 474 | fn subscription(&self) -> Subscription { 475 | // Disambiguates running subscriptions 476 | #[derive(Debug, Hash, PartialEq, Eq)] 477 | enum SubIdent { 478 | RefreshUI, 479 | AutoPoll(AccountIdent), 480 | AutoBattle(AccountIdent), 481 | AutoLure(AccountIdent), 482 | SSOCheck(SSOProvider), 483 | Crawling(usize, ServerID), 484 | } 485 | 486 | let mut subs = vec![]; 487 | let subscription = subscription::unfold( 488 | SubIdent::RefreshUI, 489 | (), 490 | move |a: ()| async move { 491 | sleep(Duration::from_millis(200)).await; 492 | (Message::UIActive, a) 493 | }, 494 | ); 495 | subs.push(subscription); 496 | 497 | for (server_id, server) in &self.servers.0 { 498 | for acc in server.accounts.values() { 499 | if self.config.auto_poll { 500 | let subscription = subscription::unfold( 501 | SubIdent::AutoPoll(acc.ident), 502 | AutoPoll { 503 | player_status: acc.status.clone(), 504 | ident: acc.ident, 505 | }, 506 | move |a: AutoPoll| async move { (a.check().await, a) }, 507 | ); 508 | subs.push(subscription); 509 | } 510 | 511 | if let Some(si) = &acc.scrapbook_info 512 | && si.auto_battle 513 | { 514 | let subscription = subscription::unfold( 515 | SubIdent::AutoBattle(acc.ident), 516 | AutoAttackChecker { 517 | player_status: acc.status.clone(), 518 | ident: acc.ident, 519 | }, 520 | move |a: AutoAttackChecker| async move { 521 | (a.check().await, a) 522 | }, 523 | ); 524 | subs.push(subscription); 525 | }; 526 | 527 | if let Some(ui) = &acc.underworld_info 528 | && ui.auto_lure 529 | { 530 | let subscription = subscription::unfold( 531 | SubIdent::AutoLure(acc.ident), 532 | AutoLureChecker { 533 | player_status: acc.status.clone(), 534 | ident: acc.ident, 535 | }, 536 | move |a: AutoLureChecker| async move { 537 | (a.check().await, a) 538 | }, 539 | ); 540 | subs.push(subscription); 541 | } 542 | } 543 | 544 | if let CrawlingStatus::Crawling { 545 | crawling_session, 546 | threads, 547 | que, 548 | .. 549 | } = &server.crawling 550 | { 551 | let Some(session) = crawling_session else { 552 | continue; 553 | }; 554 | for thread in 0..*threads { 555 | let subscription = subscription::unfold( 556 | SubIdent::Crawling(thread, server.ident.id), 557 | Crawler { 558 | que: que.clone(), 559 | state: session.clone(), 560 | server_id: *server_id, 561 | }, 562 | move |mut a: Crawler| async move { (a.crawl().await, a) }, 563 | ); 564 | subs.push(subscription); 565 | } 566 | } 567 | } 568 | 569 | for (arc, prov) in [ 570 | (&self.login_state.steam_sso, SSOProvider::Steam), 571 | (&self.login_state.google_sso, SSOProvider::Google), 572 | ] { 573 | let arc = arc.clone(); 574 | let subscription = subscription::unfold( 575 | SubIdent::SSOCheck(prov), 576 | SSOValidator { 577 | status: arc, 578 | provider: prov, 579 | }, 580 | move |a: SSOValidator| async move { 581 | let msg = match a.check().await { 582 | Ok(Some((chars, name))) => { 583 | let chars = chars.into_iter().flatten().collect(); 584 | Message::SSOSuccess { 585 | auth_name: name, 586 | chars, 587 | provider: prov, 588 | } 589 | } 590 | Ok(None) => Message::SSORetry, 591 | Err(e) => Message::SSOAuthError { 592 | _error: e.to_string(), 593 | }, 594 | }; 595 | 596 | (msg, a) 597 | }, 598 | ); 599 | subs.push(subscription); 600 | } 601 | 602 | Subscription::batch(subs) 603 | } 604 | } 605 | 606 | impl Helper { 607 | fn force_init_crawling( 608 | &mut self, 609 | url: &str, 610 | threads: usize, 611 | pb: ProgressBar, 612 | ) -> Option> { 613 | let ident = ServerIdent::new(url); 614 | let connection = ServerConnection::new(url)?; 615 | pb.enable_steady_tick(Duration::from_millis(30)); 616 | pb.set_prefix(ident.ident.to_string()); 617 | set_full_bar(&pb, "Crawling", 0); 618 | let server = self.servers.get_or_insert_default( 619 | ident, 620 | connection, 621 | Some(pb.clone()), 622 | ); 623 | 624 | let que_id = QueID::new(); 625 | 626 | let que = WorkerQue { 627 | que_id, 628 | todo_pages: Default::default(), 629 | todo_accounts: Default::default(), 630 | invalid_pages: Default::default(), 631 | invalid_accounts: Default::default(), 632 | in_flight_pages: Default::default(), 633 | in_flight_accounts: Default::default(), 634 | order: Default::default(), 635 | lvl_skipped_accounts: Default::default(), 636 | min_level: Default::default(), 637 | max_level: 9999, 638 | self_init: true, 639 | }; 640 | 641 | server.crawling = CrawlingStatus::Crawling { 642 | que_id, 643 | threads: 0, 644 | que: Arc::new(Mutex::new(que)), 645 | player_info: Default::default(), 646 | equipment: Default::default(), 647 | naked: Default::default(), 648 | last_update: Local::now(), 649 | crawling_session: None, 650 | recent_failures: Default::default(), 651 | }; 652 | Some(server.set_threads(threads, &self.config.base_name)) 653 | } 654 | 655 | fn has_accounts(&self) -> bool { 656 | self.servers.0.iter().any(|a| !a.1.accounts.is_empty()) 657 | } 658 | 659 | fn update_best( 660 | &mut self, 661 | ident: AccountIdent, 662 | keep_recent: bool, 663 | ) -> Command { 664 | trace!("Updating best for {ident:?} - keep recent: {keep_recent}"); 665 | let Some(server) = self.servers.get_mut(&ident.server_id) else { 666 | return Command::none(); 667 | }; 668 | 669 | let CrawlingStatus::Crawling { 670 | que, 671 | threads, 672 | player_info, 673 | equipment, 674 | naked, 675 | .. 676 | } = &mut server.crawling 677 | else { 678 | return Command::none(); 679 | }; 680 | 681 | let Some(account) = server.accounts.get_mut(&ident.account) else { 682 | return Command::none(); 683 | }; 684 | 685 | if keep_recent 686 | && account.last_updated + Duration::from_millis(500) >= Local::now() 687 | { 688 | return Command::none(); 689 | } 690 | 691 | let mut has_old = false; 692 | 693 | let mut lock = que.lock().unwrap(); 694 | let invalid = 695 | lock.invalid_accounts.iter().map(|a| a.as_str()).collect(); 696 | 697 | let result_limit = 50; 698 | 699 | if let Some(si) = &mut account.scrapbook_info { 700 | let per_player_counts = calc_per_player_count( 701 | player_info, equipment, &si.scrapbook.items, si, 702 | self.config.blacklist_threshold, 703 | ); 704 | let mut best_players = find_best( 705 | &per_player_counts, player_info, result_limit, &invalid, 706 | ); 707 | 708 | best_players.sort_by(|a, b| { 709 | b.missing 710 | .cmp(&a.missing) 711 | .then(a.info.stats.cmp(&b.info.stats)) 712 | .then(a.info.level.cmp(&b.info.level)) 713 | }); 714 | 715 | si.best = best_players; 716 | 717 | for target in &si.best { 718 | if target.is_old() 719 | && !lock.todo_accounts.contains(&target.info.name) 720 | && !lock.invalid_accounts.contains(&target.info.name) 721 | && !lock.in_flight_accounts.contains(&target.info.name) 722 | { 723 | has_old = true; 724 | lock.todo_accounts.push(target.info.name.to_string()) 725 | } 726 | } 727 | }; 728 | 729 | if let Some(ui) = &mut account.underworld_info { 730 | ui.best.clear(); 731 | 'a: for (_, players) in naked.range(..=ui.max_level).rev() { 732 | for player in players.iter() { 733 | if ui.best.len() >= result_limit { 734 | break 'a; 735 | } 736 | let Some(info) = player_info.get(player) else { 737 | continue; 738 | }; 739 | if info.is_old() 740 | && !lock.todo_accounts.contains(&info.name) 741 | && !lock.invalid_accounts.contains(&info.name) 742 | && !lock.in_flight_accounts.contains(&info.name) 743 | { 744 | has_old = true; 745 | lock.todo_accounts.push(info.name.to_string()) 746 | } 747 | ui.best.push(info.to_owned()); 748 | } 749 | } 750 | } 751 | drop(lock); 752 | 753 | account.last_updated = Local::now(); 754 | 755 | if (has_old || player_info.is_empty()) && *threads == 0 { 756 | return server.set_threads(1, &self.config.base_name); 757 | } 758 | Command::none() 759 | } 760 | } 761 | 762 | pub fn calc_per_player_count( 763 | player_info: &HashMap< 764 | u32, 765 | CharacterInfo, 766 | std::hash::BuildHasherDefault>, 767 | >, 768 | equipment: &HashMap< 769 | EquipmentIdent, 770 | HashSet, 771 | ahash::RandomState, 772 | >, 773 | scrapbook: &HashSet, 774 | si: &ScrapbookInfo, 775 | blacklist_th: usize, 776 | ) -> IntMap { 777 | let mut per_player_counts = IntMap::default(); 778 | per_player_counts.reserve(player_info.len()); 779 | 780 | for (eq, players) in equipment.iter() { 781 | if scrapbook.contains(eq) || eq.model_id >= 100 { 782 | continue; 783 | } 784 | for player in players.iter() { 785 | *per_player_counts.entry(*player).or_insert(0) += 1; 786 | } 787 | } 788 | 789 | per_player_counts.retain(|a, _| { 790 | let Some(info) = player_info.get(a) else { 791 | return false; 792 | }; 793 | 794 | if info.level > si.max_level { 795 | return false; 796 | } 797 | 798 | if info.stats.unwrap_or_default() > si.max_attributes { 799 | return false; 800 | } 801 | 802 | if let Some((_, lost)) = si.blacklist.get(&info.uid) 803 | && *lost >= blacklist_th.max(1) 804 | { 805 | return false; 806 | } 807 | true 808 | }); 809 | per_player_counts 810 | } 811 | 812 | macro_rules! impl_unique_id { 813 | ($type:ty) => { 814 | impl $type { 815 | fn new() -> Self { 816 | static COUNTER: AtomicU64 = AtomicU64::new(0); 817 | Self(COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst)) 818 | } 819 | } 820 | }; 821 | } 822 | 823 | #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] 824 | pub struct ServerID(u64); 825 | 826 | impl std::fmt::Display for ServerID { 827 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 828 | f.write_fmt(format_args!("server-{}", self.0)) 829 | } 830 | } 831 | 832 | #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] 833 | pub struct QueID(u64); 834 | impl_unique_id!(QueID); 835 | 836 | #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] 837 | pub struct AccountID(u64); 838 | impl_unique_id!(AccountID); 839 | 840 | impl std::fmt::Display for AccountID { 841 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 842 | f.write_fmt(format_args!("character-{}", self.0)) 843 | } 844 | } 845 | 846 | #[derive(Debug, Hash, PartialEq, Eq, Copy, Clone)] 847 | pub struct AccountIdent { 848 | server_id: ServerID, 849 | account: AccountID, 850 | } 851 | 852 | impl std::fmt::Display for AccountIdent { 853 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 854 | f.write_fmt(format_args!( 855 | "character-{}@{}", 856 | self.account.0, self.server_id.0 857 | )) 858 | } 859 | } 860 | 861 | impl ServerInfo { 862 | pub fn set_threads( 863 | &mut self, 864 | new_count: usize, 865 | base_name: &str, 866 | ) -> Command { 867 | let CrawlingStatus::Crawling { 868 | threads, 869 | crawling_session, 870 | .. 871 | } = &mut self.crawling 872 | else { 873 | return Command::none(); 874 | }; 875 | 876 | let not_logged_in = *threads == 0 && crawling_session.is_none(); 877 | 878 | *threads = new_count; 879 | 880 | let base_name = base_name.to_string(); 881 | let con = self.connection.clone(); 882 | let id = self.ident.id; 883 | 884 | if not_logged_in { 885 | Command::perform( 886 | CrawlerState::try_login(base_name, con), 887 | move |res| match res { 888 | Ok(state) => Message::CrawlerStartup { 889 | server: id, 890 | state: Arc::new(state), 891 | }, 892 | Err(err) => Message::CrawlerDied { 893 | server: id, 894 | error: err.to_string(), 895 | }, 896 | }, 897 | ) 898 | } else { 899 | Command::none() 900 | } 901 | } 902 | } 903 | 904 | #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] 905 | pub struct AttackTarget { 906 | missing: usize, 907 | info: CharacterInfo, 908 | } 909 | impl AttackTarget { 910 | fn is_old(&self) -> bool { 911 | self.info.is_old() 912 | } 913 | } 914 | 915 | fn find_best( 916 | per_player_counts: &IntMap, 917 | player_info: &IntMap, 918 | max_out: usize, 919 | invalid: &HashSet<&str>, 920 | ) -> Vec { 921 | // Prune the counts to make computation faster 922 | let mut max = 1; 923 | let mut counts = [(); 10].map(|_| vec![]); 924 | for (player, count) in per_player_counts.iter().map(|a| (*a.0, *a.1)) { 925 | if max_out == 1 && count < max || count == 0 { 926 | continue; 927 | } 928 | max = max.max(count); 929 | counts[(count - 1).clamp(0, 9)].push(player); 930 | } 931 | 932 | let mut best_players = Vec::new(); 933 | for (count, players) in counts.iter().enumerate().rev() { 934 | best_players.extend( 935 | players 936 | .iter() 937 | .flat_map(|a| player_info.get(a)) 938 | .filter(|a| !invalid.contains(&a.name.as_str())) 939 | .map(|a| AttackTarget { 940 | missing: count + 1, 941 | info: a.to_owned(), 942 | }), 943 | ); 944 | if best_players.len() >= max_out { 945 | break; 946 | } 947 | } 948 | best_players.sort_by(|a, b| b.cmp(a)); 949 | best_players.truncate(max_out); 950 | 951 | best_players 952 | } 953 | 954 | fn top_bar( 955 | center: Element, 956 | back: Option, 957 | ) -> Element { 958 | let back_button: Element = if let Some(back) = back { 959 | button("Back") 960 | .padding(4) 961 | .style(theme::Button::Destructive) 962 | .on_press(back) 963 | .into() 964 | } else { 965 | text("").into() 966 | }; 967 | 968 | let back_button = container(back_button).width(Length::Fixed(100.0)); 969 | 970 | let settings = container( 971 | button("Settings") 972 | .padding(4) 973 | .on_press(Message::ViewSettings), 974 | ) 975 | .width(Length::Fixed(100.0)) 976 | .align_x(iced::alignment::Horizontal::Right); 977 | 978 | row!( 979 | back_button, 980 | horizontal_space(), 981 | center, 982 | horizontal_space(), 983 | settings 984 | ) 985 | .align_items(Alignment::Center) 986 | .padding(15) 987 | .into() 988 | } 989 | 990 | pub fn handle_new_char_info( 991 | char: CharacterInfo, 992 | equipment: &mut HashMap< 993 | EquipmentIdent, 994 | HashSet, 995 | ahash::RandomState, 996 | >, 997 | player_info: &mut IntMap, 998 | naked: &mut BTreeMap>, 999 | ) { 1000 | let player_entry = player_info.entry(char.uid); 1001 | 1002 | const EQ_CUTOFF: usize = 4; 1003 | 1004 | match player_entry { 1005 | Entry::Occupied(mut old) => { 1006 | // We have already seen this player. We have to remove the old info 1007 | // and add the updated info 1008 | let old_info = old.get(); 1009 | for eq in &old_info.equipment { 1010 | if let Some(x) = equipment.get_mut(eq) { 1011 | x.remove(&old_info.uid); 1012 | } 1013 | } 1014 | for eq in char.equipment.clone() { 1015 | equipment 1016 | .entry(eq) 1017 | .and_modify(|a| { 1018 | a.insert(char.uid); 1019 | }) 1020 | .or_insert_with(|| { 1021 | HashSet::from_iter([char.uid].into_iter()) 1022 | }); 1023 | } 1024 | if old_info.equipment.len() < EQ_CUTOFF { 1025 | naked.entry(old_info.level).and_modify(|a| { 1026 | a.remove(&old_info.uid); 1027 | }); 1028 | } 1029 | 1030 | if char.equipment.len() < EQ_CUTOFF { 1031 | naked.entry(char.level).or_default().insert(char.uid); 1032 | } 1033 | old.insert(char); 1034 | } 1035 | Entry::Vacant(v) => { 1036 | for eq in char.equipment.clone() { 1037 | equipment 1038 | .entry(eq) 1039 | .and_modify(|a| { 1040 | a.insert(char.uid); 1041 | }) 1042 | .or_insert_with(|| { 1043 | HashSet::from_iter([char.uid].into_iter()) 1044 | }); 1045 | } 1046 | if char.equipment.len() < EQ_CUTOFF && char.level >= 100 { 1047 | naked.entry(char.level).or_default().insert(char.uid); 1048 | } 1049 | v.insert(char); 1050 | } 1051 | } 1052 | } 1053 | 1054 | fn get_log_config(is_headless: bool) -> log4rs::Config { 1055 | let pattern = PatternEncoder::new( 1056 | "{d(%Y-%m-%d %H:%M:%S)} | {({l}):5.5} | {M}:{L} | {m}{n}", 1057 | ); 1058 | let stderr = ConsoleAppender::builder() 1059 | .target(Target::Stderr) 1060 | .encoder(Box::new(pattern.clone())) 1061 | .build(); 1062 | 1063 | let logfile = FileAppender::builder() 1064 | .encoder(Box::new(pattern.clone())) 1065 | .build("helper.log") 1066 | .unwrap(); 1067 | 1068 | let mut logger = log4rs::Config::builder() 1069 | .appender(Appender::builder().build("logfile", Box::new(logfile))); 1070 | let mut root = Root::builder(); 1071 | 1072 | if !is_headless { 1073 | logger = logger 1074 | .appender(Appender::builder().build("stderr", Box::new(stderr))); 1075 | root = root.appender("stderr"); 1076 | } 1077 | 1078 | logger 1079 | .logger( 1080 | Logger::builder() 1081 | .appender("logfile") 1082 | .build("sf_scrapbook_helper", log::LevelFilter::Debug), 1083 | ) 1084 | .logger( 1085 | Logger::builder() 1086 | .appender("logfile") 1087 | .build("sf_api", log::LevelFilter::Warn), 1088 | ) 1089 | .build(root.build(log::LevelFilter::Error)) 1090 | .unwrap() 1091 | } 1092 | 1093 | async fn check_update() -> Result> { 1094 | sleep(Duration::from_millis(fastrand::u64(500..=5000))).await; 1095 | let client = reqwest::ClientBuilder::new() 1096 | .user_agent("sf-scrapbook-helper") 1097 | .build()?; 1098 | let url = 1099 | "https://api.github.com/repos/the-marenga/sf-scrapbook-helper/tags"; 1100 | let resp = client.get(url).send().await?; 1101 | 1102 | let text = resp.text().await?; 1103 | 1104 | #[derive(Debug, Deserialize)] 1105 | struct GitTag { 1106 | name: String, 1107 | } 1108 | 1109 | let tags: Vec = serde_json::from_str(&text)?; 1110 | 1111 | let mut should_update = false; 1112 | if let Some(newest) = tags.first() { 1113 | let git_version = 1114 | semver::Version::parse(newest.name.trim_start_matches('v'))?; 1115 | let own_version = semver::Version::parse(env!("CARGO_PKG_VERSION"))?; 1116 | should_update = own_version < git_version; 1117 | } 1118 | Ok(should_update) 1119 | } 1120 | 1121 | pub fn set_full_bar(bar: &ProgressBar, title: &str, length: usize) { 1122 | let style = ProgressStyle::default_spinner() 1123 | .template( 1124 | "{spinner} {prefix:17.red} - {msg:25.blue} {wide_bar:.green} \ 1125 | [{elapsed_precise}/{duration_precise}] [{pos:6}/{len:6}]", 1126 | ) 1127 | .unwrap_or_else(|_| ProgressStyle::default_spinner()); 1128 | 1129 | bar.set_style(style); 1130 | bar.reset_elapsed(); 1131 | bar.set_message(title.to_string()); 1132 | bar.set_length(length as u64); 1133 | bar.set_position(0); 1134 | } 1135 | --------------------------------------------------------------------------------