include combined time + weather windows

This commit is contained in:
insects 2025-02-05 14:54:10 +01:00
parent c089f20b87
commit a1ca56dc82
4 changed files with 138 additions and 42 deletions

View file

@ -1,22 +1,35 @@
// Mostly nicked from https://github.com/icykoneko/ff14-fish-tracker-app/blob/master/js/app/time.js. Thanks! // 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 EARTH_TO_EORZEA: f64 = 3600. / 175.;
const EORZEA_TO_EARTH: f64 = 1. / EARTH_TO_EORZEA; 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<Utc>) -> DateTime<Utc> { pub fn to_eorzea_time(earth_time: DateTime<Utc>) -> DateTime<Utc> {
let et_ts = earth_time.timestamp(); let et_ts = earth_time.timestamp();
let new_ts = et_ts.abs() as f64 * EARTH_TO_EORZEA; let new_ts = et_ts.abs() as f64 * EARTH_TO_EORZEA;
DateTime::from_timestamp(new_ts.floor() as i64, 0).unwrap() 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<Utc> { pub fn get_current_eorzea_date() -> DateTime<Utc> {
to_eorzea_time(Utc::now()) to_eorzea_time(Utc::now())
} }
/// Converts an Eorzean timestamp to human time.
pub fn to_earth_time(ez_time: DateTime<Utc>) -> DateTime<Utc> { pub fn to_earth_time(ez_time: DateTime<Utc>) -> DateTime<Utc> {
let ez_ts = ez_time.timestamp(); let ez_ts = ez_time.timestamp();
let new_ts = (ez_ts.abs() as f64 * EORZEA_TO_EARTH).ceil(); let new_ts = (ez_ts.abs() as f64 * EORZEA_TO_EARTH).ceil();
DateTime::from_timestamp(new_ts as i64, 0).unwrap() 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<Utc>, hm: f32) -> DateTime<Utc> {
let minutes = ((hm % 1.) * 100.) as u32;
date.date_naive()
.and_hms_opt(hm as u32, minutes, date.second())
.unwrap()
.and_utc()
}

View file

@ -1,11 +1,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use chrono::Timelike; use chrono::{DateTime, Duration, Timelike, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
clock, 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"); const DATA: &'static str = include_str!("../data.json");
@ -32,7 +34,7 @@ pub struct SubData {
pub weather_types: HashMap<u32, WeatherType>, pub weather_types: HashMap<u32, WeatherType>,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, Default)]
#[serde(rename_all(deserialize = "camelCase"))] #[serde(rename_all(deserialize = "camelCase"))]
pub struct FishEntry { pub struct FishEntry {
#[serde(alias = "_id")] #[serde(alias = "_id")]
@ -83,7 +85,7 @@ pub struct Location {
pub map_coords: (f32, f32, u32), pub map_coords: (f32, f32, u32),
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, Default)]
pub struct FishMeta { pub struct FishMeta {
pub id: u32, pub id: u32,
pub name_en: String, pub name_en: String,
@ -92,14 +94,23 @@ pub struct FishMeta {
pub struct CombinedFish<'a> { pub struct CombinedFish<'a> {
pub entry: &'a FishEntry, pub entry: &'a FishEntry,
pub meta: &'a FishMeta, pub meta: &'a FishMeta,
pub is_up: bool,
pub next_uptime: DateTime<Utc>,
} }
impl<'a> CombinedFish<'a> { 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<Utc>) -> bool {
if self.entry.start_hour.is_none() || self.entry.end_hour.is_none() { if self.entry.start_hour.is_none() || self.entry.end_hour.is_none() {
return false; 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 start_hour = self.entry.start_hour.unwrap();
let end_hour = self.entry.end_hour.unwrap(); let end_hour = self.entry.end_hour.unwrap();
let spans_midnight = start_hour > end_hour; 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> { pub fn get_forecast<'b>(&self, data: &'b Data) -> Option<&'b Forecast> {
if self.entry.location.is_none() { if self.entry.location.is_none() {
return None; return None;
@ -124,12 +136,13 @@ impl<'a> CombinedFish<'a> {
data.forecasts.get(&spot.forecast_id) 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<Utc>) -> bool {
if self.entry.weather_set.is_empty() { if self.entry.weather_set.is_empty() {
return true; return true;
} }
// Check if the current weather is right! // Check if the current weather is right!
let cur_weather = forecast.weather_now(); let cur_weather = forecast.weather_at(date);
let is_current = self let is_current = self
.entry .entry
.weather_set .weather_set
@ -141,7 +154,8 @@ impl<'a> CombinedFish<'a> {
let is_past = if self.entry.previous_weather_set.is_empty() { let is_past = if self.entry.previous_weather_set.is_empty() {
true true
} else { } 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 self.entry
.previous_weather_set .previous_weather_set
.iter() .iter()
@ -151,10 +165,71 @@ impl<'a> CombinedFish<'a> {
is_current && is_past 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); let forecast = self.get_forecast(data);
self.is_in_time_range() let date = clock::get_current_eorzea_date() + Duration::hours(n);
&& forecast.is_none_or(|forecast| self.is_in_correct_weather(forecast)) 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<Utc> {
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<DateTime<Utc>> {
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<Utc> {
// 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() .iter()
.filter_map(|(k, v)| { .filter_map(|(k, v)| {
let corresponding_meta = self.fish_entries.iter().find(|m| &m.id == k); 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() .collect()
} }

View file

@ -1,4 +1,4 @@
use std::{collections::HashMap, f32::consts::PI}; use std::collections::HashMap;
use chrono::{DateTime, Duration, Timelike, Utc}; use chrono::{DateTime, Duration, Timelike, Utc};
@ -27,44 +27,51 @@ pub struct Rate {
pub type ForecastSet = HashMap<u32, Forecast>; pub type ForecastSet = HashMap<u32, Forecast>;
impl Forecast { impl Forecast {
/// Calculates the current weather for the given target magic number.
pub fn weather_for_target(&self, target: u32) -> &Rate { pub fn weather_for_target(&self, target: u32) -> &Rate {
// TODO: Don't unwrap here! // TODO: Don't unwrap here!
self.rates.iter().find(|r| target < r.rate).unwrap() self.rates.iter().find(|r| target < r.rate).unwrap()
} }
/// Calculates the weather right now.
pub fn weather_now(&self) -> &Rate { pub fn weather_now(&self) -> &Rate {
let utc = Utc::now(); let utc = Utc::now();
let target = calculate_target(utc); let target = calculate_target(utc);
self.weather_for_target(target) self.weather_for_target(target)
} }
/// Returns the weather `n` cycles before or after the current weather. /// Calculates the weather at a specific point in Eorzean time.
pub fn nth_weather(&self, n: i32) -> &Rate { pub fn weather_at(&self, date: DateTime<Utc>) -> &Rate {
let last_weather_time = round_to_last_weather_time(&clock::get_current_eorzea_date()); let new_date = round_to_last_weather_time(date);
let hours = n * 8; let target = calculate_target(clock::to_earth_time(new_date));
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);
self.weather_for_target(target) self.weather_for_target(target)
} }
} }
/// Rounds to the last weather "start". These happen three times a day, at 0:00, /// Rounds to the last weather "start". These happen three times a day, at 0:00,
/// at 8:00, and at 16:00. /// at 8:00, and at 16:00.
pub fn round_to_last_weather_time(date: &DateTime<Utc>) -> DateTime<Utc> { pub fn round_to_last_weather_time(date: DateTime<Utc>) -> DateTime<Utc> {
let cur_hour = date.hour(); let cur_hour = date.hour();
// This essentially performs division without rest. // This essentially performs division without rest.
let last_hour = (cur_hour / 8) * 8; let last_hour = (cur_hour / 8) * 8;
date.date_naive() date.date_naive()
.and_hms_opt(last_hour, date.minute(), date.second()) .and_hms_opt(last_hour, 0, 0)
.unwrap() .unwrap()
.and_utc() .and_utc()
} }
/// Adds or subtracts `n` intervals of 8 hours to the last weather time.
pub fn modify_weather_time(n: i32) -> DateTime<Utc> {
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. /// Calculates the magic target number for a specific date.
/// It's important to note that this expects a human time, not an Eorzean time. /// It's important to note that this expects a human time, not an Eorzean time.
pub fn calculate_target(m: DateTime<Utc>) -> u32 { pub fn calculate_target(m: DateTime<Utc>) -> u32 {

View file

@ -36,24 +36,16 @@ where
#[axum::debug_handler] #[axum::debug_handler]
async fn main_handler(state: State<Arc<AppState>>) -> Result<Markup, AppError> { async fn main_handler(state: State<Arc<AppState>>) -> Result<Markup, AppError> {
let meta = state.data.fish_with_meta(); let meta = state.data.fish_with_meta();
let mut values: Vec<(&CombinedFish, bool)> = meta let mut values: Vec<&CombinedFish> = meta.values().filter(|f| f.entry.big_fish).collect();
.values() values.sort_by(|afish, bfish| bfish.is_up.cmp(&afish.is_up));
.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));
Ok(html! { Ok(html! {
h1 { "Hello! Current ET " (clock::get_current_eorzea_date().format("%H:%M")) } h1 { "Hello! Current ET " (clock::get_current_eorzea_date().format("%H:%M")) }
@for (fish, is_up) in values { @for fish in values {
li { li {
@if is_up { @if fish.is_up {
"Up! " "Up! "
} @else {
"Next uptime " (fish.next_uptime)
} }
(fish.meta.name_en) (fish.meta.name_en)
details { details {