From a1ca56dc82ff131677f926daf6a8e99966a239ec Mon Sep 17 00:00:00 2001 From: insects Date: Wed, 5 Feb 2025 14:54:10 +0100 Subject: [PATCH] include combined time + weather windows --- src/clock.rs | 15 ++++++- src/data.rs | 110 ++++++++++++++++++++++++++++++++++++++++++------ src/forecast.rs | 35 +++++++++------ src/main.rs | 20 +++------ 4 files changed, 138 insertions(+), 42 deletions(-) diff --git a/src/clock.rs b/src/clock.rs index 7c111f6..39b3509 100644 --- a/src/clock.rs +++ b/src/clock.rs @@ -1,22 +1,35 @@ // Mostly nicked from https://github.com/icykoneko/ff14-fish-tracker-app/blob/master/js/app/time.js. Thanks! -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Timelike, Utc}; const EARTH_TO_EORZEA: f64 = 3600. / 175.; const EORZEA_TO_EARTH: f64 = 1. / EARTH_TO_EORZEA; +/// Converts a human timestamp to an Eorzean one. pub fn to_eorzea_time(earth_time: DateTime) -> DateTime { let et_ts = earth_time.timestamp(); let new_ts = et_ts.abs() as f64 * EARTH_TO_EORZEA; DateTime::from_timestamp(new_ts.floor() as i64, 0).unwrap() } +/// Gets the current date and time in Eorzea. Arguably you will never need the date, though, +/// because it isn't shown anywhere in the game, the time is what matters. pub fn get_current_eorzea_date() -> DateTime { to_eorzea_time(Utc::now()) } +/// Converts an Eorzean timestamp to human time. pub fn to_earth_time(ez_time: DateTime) -> DateTime { let ez_ts = ez_time.timestamp(); let new_ts = (ez_ts.abs() as f64 * EORZEA_TO_EARTH).ceil(); DateTime::from_timestamp(new_ts as i64, 0).unwrap() } + +/// Sets hours and minutes for a date, based on a f32. +pub fn set_hm_from_float(date: &DateTime, hm: f32) -> DateTime { + let minutes = ((hm % 1.) * 100.) as u32; + date.date_naive() + .and_hms_opt(hm as u32, minutes, date.second()) + .unwrap() + .and_utc() +} diff --git a/src/data.rs b/src/data.rs index aba5924..cf62ecb 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,11 +1,13 @@ use std::collections::HashMap; -use chrono::Timelike; +use chrono::{DateTime, Duration, Timelike, Utc}; use serde::{Deserialize, Serialize}; use crate::{ clock, - forecast::{Forecast, ForecastSet, Rate}, + forecast::{ + self, modify_weather_time, round_to_last_weather_time, Forecast, ForecastSet, Rate, + }, }; const DATA: &'static str = include_str!("../data.json"); @@ -32,7 +34,7 @@ pub struct SubData { pub weather_types: HashMap, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] #[serde(rename_all(deserialize = "camelCase"))] pub struct FishEntry { #[serde(alias = "_id")] @@ -83,7 +85,7 @@ pub struct Location { pub map_coords: (f32, f32, u32), } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default)] pub struct FishMeta { pub id: u32, pub name_en: String, @@ -92,14 +94,23 @@ pub struct FishMeta { pub struct CombinedFish<'a> { pub entry: &'a FishEntry, pub meta: &'a FishMeta, + pub is_up: bool, + pub next_uptime: DateTime, } impl<'a> CombinedFish<'a> { - pub fn is_in_time_range(&self) -> bool { + /// 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); + } + + /// Finds out whether the fish is technically up at the given time (discounting factors like weather). + pub fn is_in_time_range_at(&self, date: DateTime) -> bool { if self.entry.start_hour.is_none() || self.entry.end_hour.is_none() { return false; } - let et = clock::get_current_eorzea_date(); + let et = clock::to_eorzea_time(date); let start_hour = self.entry.start_hour.unwrap(); let end_hour = self.entry.end_hour.unwrap(); let spans_midnight = start_hour > end_hour; @@ -111,6 +122,7 @@ impl<'a> CombinedFish<'a> { } } + /// Gets the associated forecast struct. pub fn get_forecast<'b>(&self, data: &'b Data) -> Option<&'b Forecast> { if self.entry.location.is_none() { return None; @@ -124,12 +136,13 @@ impl<'a> CombinedFish<'a> { data.forecasts.get(&spot.forecast_id) } - pub fn is_in_correct_weather(&self, forecast: &Forecast) -> bool { + /// Finds out whether the fish is in favorable weather conditions at a specific Eorzean time. + pub fn is_in_correct_weather_at(&self, forecast: &Forecast, date: DateTime) -> bool { if self.entry.weather_set.is_empty() { return true; } // Check if the current weather is right! - let cur_weather = forecast.weather_now(); + let cur_weather = forecast.weather_at(date); let is_current = self .entry .weather_set @@ -141,7 +154,8 @@ impl<'a> CombinedFish<'a> { let is_past = if self.entry.previous_weather_set.is_empty() { true } else { - let prev_weather = forecast.nth_weather(-1); + let prev_date = date - Duration::hours(8); + let prev_weather = forecast.weather_at(prev_date); self.entry .previous_weather_set .iter() @@ -151,10 +165,71 @@ impl<'a> CombinedFish<'a> { is_current && is_past } - pub fn is_up(&self, data: &Data) -> bool { + /// Finds out if the fish is up now, includes weather and time bounds. + pub fn is_up_now(&self, data: &Data) -> bool { + self.is_up_at_n(data, 0) + } + + /// Finds out if the fish is up at a specific time, `n` Eorzean hours away. + pub fn is_up_at_n(&self, data: &Data, n: i64) -> bool { let forecast = self.get_forecast(data); - self.is_in_time_range() - && forecast.is_none_or(|forecast| self.is_in_correct_weather(forecast)) + let date = clock::get_current_eorzea_date() + Duration::hours(n); + self.is_in_time_range_at(date) + && forecast.is_none_or(|forecast| self.is_in_correct_weather_at(forecast, date)) + } + + /// Get the next uptime for a fish that is only restricted by time. + pub fn get_next_time_uptime(&self) -> DateTime { + let ez_date = clock::get_current_eorzea_date(); + // Check if we have a minute part to our start time, which can happen. + let adjusted_date = clock::set_hm_from_float(&ez_date, self.entry.start_hour.unwrap()); + clock::to_earth_time(adjusted_date) + } + + /// Get the next times when the weather is favorable for a fish. + pub fn get_next_weather_uptimes(&self, data: &Data, cycles: u32) -> Vec> { + let forecast = self.get_forecast(data).unwrap(); + // Skip ahead by 8-hour intervals, to quickly find the next favorable weather. + let mut date = round_to_last_weather_time(clock::get_current_eorzea_date()); + // Start with the next weather cycle. + date = date + Duration::hours(8); + let mut results = Vec::new(); + for i in 1..cycles { + while !self.is_in_correct_weather_at(forecast, date) { + date = date + Duration::hours(8); + } + results.push(date.clone()); + date = date + Duration::hours(8); + } + 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. + 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 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)); + } + } + + // FIXME: This should never be called? + Utc::now() } } @@ -192,7 +267,16 @@ impl Data { .iter() .filter_map(|(k, v)| { let corresponding_meta = self.fish_entries.iter().find(|m| &m.id == k); - corresponding_meta.map(|m| (k.clone(), CombinedFish { entry: v, meta: m })) + corresponding_meta.map(|m| { + let mut cfish = CombinedFish { + entry: v, + meta: m, + is_up: false, // fake default values for now + next_uptime: Utc::now(), // dito + }; + cfish.find_uptime(self); + (k.clone(), cfish) + }) }) .collect() } diff --git a/src/forecast.rs b/src/forecast.rs index 70a6343..abf69f4 100644 --- a/src/forecast.rs +++ b/src/forecast.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, f32::consts::PI}; +use std::collections::HashMap; use chrono::{DateTime, Duration, Timelike, Utc}; @@ -27,44 +27,51 @@ pub struct Rate { pub type ForecastSet = HashMap; impl Forecast { + /// Calculates the current weather for the given target magic number. pub fn weather_for_target(&self, target: u32) -> &Rate { // TODO: Don't unwrap here! self.rates.iter().find(|r| target < r.rate).unwrap() } + /// Calculates the weather right now. pub fn weather_now(&self) -> &Rate { let utc = Utc::now(); let target = calculate_target(utc); self.weather_for_target(target) } - /// Returns the weather `n` cycles before or after the current weather. - pub fn nth_weather(&self, n: i32) -> &Rate { - let last_weather_time = round_to_last_weather_time(&clock::get_current_eorzea_date()); - let hours = n * 8; - let td = Duration::hours(hours.abs() as i64); - let new_date = if hours > 0 { - last_weather_time.checked_add_signed(td).unwrap() - } else { - last_weather_time.checked_sub_signed(td).unwrap() - }; - let target = calculate_target(new_date); + /// Calculates the weather at a specific point in Eorzean time. + pub fn weather_at(&self, date: DateTime) -> &Rate { + let new_date = round_to_last_weather_time(date); + let target = calculate_target(clock::to_earth_time(new_date)); self.weather_for_target(target) } } /// Rounds to the last weather "start". These happen three times a day, at 0:00, /// at 8:00, and at 16:00. -pub fn round_to_last_weather_time(date: &DateTime) -> DateTime { +pub fn round_to_last_weather_time(date: DateTime) -> DateTime { let cur_hour = date.hour(); // This essentially performs division without rest. let last_hour = (cur_hour / 8) * 8; date.date_naive() - .and_hms_opt(last_hour, date.minute(), date.second()) + .and_hms_opt(last_hour, 0, 0) .unwrap() .and_utc() } +/// Adds or subtracts `n` intervals of 8 hours to the last weather time. +pub fn modify_weather_time(n: i32) -> DateTime { + let last_weather_time = round_to_last_weather_time(clock::get_current_eorzea_date()); + let hours = n * 8; + let td = Duration::hours(hours.abs() as i64); + if hours > 0 { + last_weather_time.checked_add_signed(td).unwrap() + } else { + last_weather_time.checked_sub_signed(td).unwrap() + } +} + /// Calculates the magic target number for a specific date. /// It's important to note that this expects a human time, not an Eorzean time. pub fn calculate_target(m: DateTime) -> u32 { diff --git a/src/main.rs b/src/main.rs index 116ef03..eb67ab5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,24 +36,16 @@ where #[axum::debug_handler] async fn main_handler(state: State>) -> Result { let meta = state.data.fish_with_meta(); - let mut values: Vec<(&CombinedFish, bool)> = meta - .values() - .filter_map(|fish| { - if fish.entry.big_fish { - let is_up = fish.is_up(&state.data); - Some((fish, is_up)) - } else { - None - } - }) - .collect(); - values.sort_by(|(_, aup), (_, bup)| bup.cmp(aup)); + 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)); Ok(html! { h1 { "Hello! Current ET " (clock::get_current_eorzea_date().format("%H:%M")) } - @for (fish, is_up) in values { + @for fish in values { li { - @if is_up { + @if fish.is_up { "Up! " + } @else { + "Next uptime " (fish.next_uptime) } (fish.meta.name_en) details {