diff --git a/src/clock.rs b/src/clock.rs index 86b0e9a..7bb6103 100644 --- a/src/clock.rs +++ b/src/clock.rs @@ -1,6 +1,6 @@ // Mostly nicked from https://github.com/icykoneko/ff14-fish-tracker-app/blob/master/js/app/time.js. Thanks! -use chrono::{DateTime, Timelike, Utc}; +use chrono::{DateTime, Duration, Timelike, Utc}; const EARTH_TO_EORZEA: f64 = 3600. / 175.; const EORZEA_TO_EARTH: f64 = 1. / EARTH_TO_EORZEA; @@ -36,6 +36,17 @@ pub fn set_hm_from_float(date: &DateTime, hm: f32) -> DateTime { .and_utc() } +/// Calculates the length between a window start time and end time, and returns it as a Duration. +pub fn get_window_duration(start_time: f32, end_time: f32) -> Duration { + let diff_float = if end_time > start_time { + end_time - start_time + } else { + (24. - start_time) + end_time + }; + let minutes = (diff_float - (diff_float as u32 as f32) * 60.) as u32; + Duration::minutes(((diff_float as u32) * 60 + minutes) as i64) +} + pub fn display_eorzea_time(date: &DateTime) -> String { date.format("%H:%M").to_string() } diff --git a/src/data.rs b/src/data.rs index f48fd6f..191b6c4 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use chrono::{DateTime, Duration, Timelike, Utc}; +use chrono::{DateTime, Datelike, Duration, Timelike, Utc}; use serde::{Deserialize, Serialize}; use crate::{ @@ -93,14 +93,47 @@ pub struct CombinedFish<'a> { pub entry: &'a FishEntry, pub meta: &'a FishMeta, pub is_up: bool, - pub next_uptime: DateTime, + pub is_always_up: bool, + pub windows: Vec, + pub rarity: f32, +} + +#[derive(Debug)] +pub struct Window { + pub start_time: DateTime, + pub duration: Duration, } impl<'a> CombinedFish<'a> { /// Fills in the rest of the struct. pub fn find_uptime(&mut self, data: &Data) { - self.is_up = self.is_up_now(data); - self.next_uptime = self.get_next_uptime(data); + // If the start and end times are equal, and there's no weather constraint, the fish is always up. + if self + .entry + .start_hour + .is_some_and(|f| self.entry.end_hour.is_some_and(|g| f == 0. && g == 24.)) + && self.entry.weather_set.is_empty() + { + self.is_always_up = true; + return; + } + self.windows = self.get_next_uptimes(data); + let now = clock::get_current_eorzea_date(); + let first_window = self.windows.first(); + self.is_up = if let Some(window) = first_window { + window.start_time < now && (window.start_time + window.duration) > now + } else { + false + }; + + // Find the rarity of the fish. We define this as the percentage of uptime in the next 10 days. + let duration = clock::to_eorzea_time(Utc::now() + Duration::days(10)) + - clock::get_current_eorzea_date(); + let up_durations = self + .windows + .iter() + .fold(Duration::zero(), |acc, w| acc + w.duration); + self.rarity = up_durations.num_minutes() as f32 / duration.num_minutes() as f32; } /// Finds out whether the fish is technically up at the given time (discounting factors like weather). @@ -202,32 +235,157 @@ impl<'a> CombinedFish<'a> { results } - // Go until you find the next uptime slot, and return its date. - pub fn get_next_uptime(&self, data: &Data) -> DateTime { - // Is this a time-only fish? If so, just return the next time. + // Given a forecast and a start date of a window, find how long that weather stays. + pub fn get_weather_duration(&self, forecast: &Forecast, date: &DateTime) -> Duration { + let mut length = 0; + let mut date = date.clone(); + let mut cur_pattern = forecast.weather_at(date).weather_id; + while self.entry.weather_set.iter().any(|w| w == &cur_pattern) { + length += 8; + date = date + Duration::hours(8); + cur_pattern = forecast.weather_at(date).weather_id; + } + Duration::hours(length) + } + + // Calculate the uptime windows occurring in the next 10 days. + pub fn get_next_uptimes(&self, data: &Data) -> Vec { + let mut idx_date = round_to_last_weather_time(clock::get_current_eorzea_date()); + let goal_date = clock::to_eorzea_time(clock::to_earth_time(idx_date) + Duration::days(10)); + + // If it's a time-only fish, the procedure is a bit simpler. if self.entry.weather_set.is_empty() && self.entry.start_hour.is_some() { - return clock::to_earth_time(self.get_next_time_uptime()); - } - - // If not, check if there's a weather-only fish - let next_weather_uptimes = self.get_next_weather_uptimes(data, 50); - if !self.entry.weather_set.is_empty() && self.entry.start_hour.is_none() { - return clock::to_earth_time(next_weather_uptimes.first().unwrap().clone()); - } - - // Now it gets a little bit tricky: We need to find a weather slot that also includes the start point for the - // time window. - for date in next_weather_uptimes { - // Does the start time lie within this window? - let uptime_end = date + Duration::hours(8); + let mut idx_date = clock::get_current_eorzea_date(); // We don't need to round to a weather time here. + let mut results = Vec::new(); let start_hour = self.entry.start_hour.unwrap(); - if date.hour() as f32 <= start_hour && start_hour <= uptime_end.hour() as f32 { - return clock::to_earth_time(clock::set_hm_from_float(&date, start_hour)); + let end_hour = self.entry.end_hour.unwrap(); + while idx_date < goal_date { + // Because there will be a window every single day, just set our clock to when it starts. + let next_start = + clock::set_hm_from_float(&idx_date, self.entry.start_hour.unwrap()); + let duration = clock::get_window_duration(start_hour, end_hour); + // Safely push the full duration. + results.push(Window { + start_time: next_start, + duration, + }); + // If the end time is on the same day, push the clock ahead by a day so that we don't use the same + // window the next iteration. + let next_end = next_start + duration; + if next_start.day() == next_end.day() { + idx_date = idx_date + Duration::days(1); + } else { + idx_date = next_end; + } + } + return results; + } + + // If our fish is weather-affected, we need to be a bit more careful. + let forecast = self.get_forecast(data).unwrap(); + let has_time_constraint = + !(self.entry.start_hour.unwrap() == 0. && self.entry.end_hour.unwrap() == 24.); + let mut results = Vec::new(); + // No use in running this loop if the fish is up all the time. + if !has_time_constraint && self.entry.weather_set.is_empty() { + return results; + } + while idx_date < goal_date { + // Is our fish technically in favorable weather here? + let has_weather = self.is_in_correct_weather_at(forecast, idx_date); + // If we don't have a time constraint, and the weather is good, add to our results. + + if !has_time_constraint && has_weather { + // The weather might drag on for more than one cycle, find out when it ends. + // However: A weather constraint with a previous weather set will never have two consecutive uptime cycles. + let duration = if self.entry.previous_weather_set.is_empty() { + self.get_weather_duration(forecast, &idx_date) + } else { + Duration::hours(8) + }; + results.push(Window { + start_time: idx_date, + duration, + }); + + idx_date = idx_date + duration; + } else if has_time_constraint && has_weather { + // Calculate start and end times for both the weather uptime and the time-based uptime. + let w_duration = if self.entry.previous_weather_set.is_empty() { + self.get_weather_duration(forecast, &idx_date) + } else { + Duration::hours(8) + }; + let w_start_date = idx_date; + let w_end_date = w_start_date + w_duration; + let start_hour = self.entry.start_hour.unwrap(); + let end_hour = self.entry.end_hour.unwrap(); + // If the current weather cycle is the midnight one, and the timespan wraps a day, start + // at the previous day. + let t_start_date = if idx_date.hour() == 0 && start_hour > end_hour { + clock::set_hm_from_float( + &(idx_date - Duration::days(1)), + self.entry.start_hour.unwrap(), + ) + } else { + clock::set_hm_from_float(&idx_date, self.entry.start_hour.unwrap()) + }; + let t_duration = clock::get_window_duration( + self.entry.start_hour.unwrap(), + self.entry.end_hour.unwrap(), + ); + let t_end_date = t_start_date + t_duration; + // We have to check four cases: + if t_start_date <= w_start_date && t_end_date >= w_end_date { + // CASE 1: The time-based uptime exceeds the weather-based uptime at start and end + // This is the easiest: Just save the full weather-based uptime. + results.push(Window { + start_time: w_start_date, + duration: w_duration, + }); + } else if w_start_date <= t_start_date && w_end_date >= t_end_date { + // CASE 2: Vice-versa, the weather-based uptime exceeds the time-based uptime. + // Also just save the time-based uptime. + results.push(Window { + start_time: t_start_date, + duration: t_duration, + }) + } else if w_start_date <= t_start_date + && t_end_date >= w_end_date + && w_end_date > t_start_date + { + // CASE 3: The weather starts before the time, but the doesn't last beyond it. + // In this case, save the time-based start time, and the duration between time-based start time + // and weather-based end time. + let diff = w_end_date - t_start_date; + results.push(Window { + start_time: t_start_date, + duration: diff, + }); + } else if t_start_date <= w_start_date + && w_end_date >= t_end_date + && t_end_date > w_start_date + { + // CASE 4: The time starts before the weather, but doesn't last beyond it. + // Do the opposite of Case 3 here. + let diff = t_end_date - w_start_date; + results.push(Window { + start_time: w_start_date, + duration: diff, + }) + } + + idx_date = idx_date + w_duration; + } else { + idx_date = idx_date + Duration::hours(8); } } - // FIXME: This should never be called? - Utc::now() + if self.meta.name_en == "The Drowned Sniper" { + dbg!(&results[1]); + dbg!(clock::to_earth_time(results[1].start_time)); + } + results } } @@ -269,8 +427,10 @@ impl Data { let mut cfish = CombinedFish { entry: v, meta: m, - is_up: false, // fake default values for now - next_uptime: Utc::now(), // dito + is_up: false, // fake default values for now + is_always_up: false, // dito + windows: Vec::new(), // dito + rarity: 1., // dito }; cfish.find_uptime(self); (k.clone(), cfish) diff --git a/src/templates.rs b/src/templates.rs index 822666c..f3073b7 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,13 +1,9 @@ -use std::sync::Arc; +use std::{cmp::Ordering, sync::Arc}; use axum::extract::State; use maud::{html, Markup, DOCTYPE}; -use crate::{ - clock, - data::{self, CombinedFish}, - AppState, -}; +use crate::{clock, data::CombinedFish, AppState}; pub fn layout(content: Markup) -> Markup { html! { @@ -27,22 +23,41 @@ pub fn layout(content: Markup) -> Markup { pub fn main_page(state: State>) -> Markup { let meta = state.data.fish_with_meta(); let mut values: Vec<&CombinedFish> = meta.values().filter(|f| f.entry.big_fish).collect(); - values.sort_by(|afish, bfish| bfish.is_up.cmp(&afish.is_up)); + values.sort_by(|afish, bfish| { + bfish + .is_up + .cmp(&afish.is_up) + .then(bfish.is_always_up.cmp(&afish.is_always_up)) + .then(bfish.rarity.total_cmp(&afish.rarity).reverse()) + // if (afish.is_up || afish.is_always_up) && !(bfish.is_up && bfish.is_always_up) { + // Ordering::Less + // } else { + // Ordering::Greater + // } + }); layout(html! { h1 { "Hello! Current ET: " (clock::get_current_eorzea_date().format("%H:%M")) } @for fish in values { - section.up[fish.is_up] { + section.up[fish.is_up || fish.is_always_up] { .title { h3 { (fish.meta.name_en) } + .subtitle { + (fish.entry.patch) + } } .meta { @if fish.entry.start_hour.is_some() && fish.entry.end_hour.is_some() { div { - (clock::display_eorzea_time(&clock::set_hm_from_float(&clock::get_current_eorzea_date(), fish.entry.start_hour.unwrap()))) - " to " - (clock::display_eorzea_time(&clock::set_hm_from_float(&clock::get_current_eorzea_date(), fish.entry.end_hour.unwrap()))) + @if !fish.is_always_up { + (clock::display_eorzea_time(&clock::set_hm_from_float(&clock::get_current_eorzea_date(), fish.entry.start_hour.unwrap()))) + " to " + (clock::display_eorzea_time(&clock::set_hm_from_float(&clock::get_current_eorzea_date(), fish.entry.end_hour.unwrap()))) + } @else { + "always up!" + } } } + div { "Rarity: " (format!("{:.2}", fish.rarity * 100.)) "%" } } } } diff --git a/static/style.css b/static/style.css index d46164c..289f91c 100644 --- a/static/style.css +++ b/static/style.css @@ -11,6 +11,21 @@ section { padding: 0 10px; } +.title { + padding: 5px 0; +} + +.title h3 { + margin: 0; + padding: 0; +} + +.title .subtitle { + margin: 0; + font-size: 14px; + color: gray; +} + section.up { background-color: greenyellow; }