├── 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 | #
S&F Scrapbook Helper
5 |  [](https://opensource.org/licenses/MIT) [](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