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!
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<Utc>) -> DateTime<Utc> {
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<Utc> {
to_eorzea_time(Utc::now())
}
/// Converts an Eorzean timestamp to human time.
pub fn to_earth_time(ez_time: DateTime<Utc>) -> DateTime<Utc> {
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<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 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<u32, WeatherType>,
}
#[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<Utc>,
}
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() {
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<Utc>) -> 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<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()
.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()
}

View file

@ -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<u32, Forecast>;
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<Utc>) -> &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<Utc>) -> DateTime<Utc> {
pub fn round_to_last_weather_time(date: DateTime<Utc>) -> DateTime<Utc> {
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<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.
/// It's important to note that this expects a human time, not an Eorzean time.
pub fn calculate_target(m: DateTime<Utc>) -> u32 {

View file

@ -36,24 +36,16 @@ where
#[axum::debug_handler]
async fn main_handler(state: State<Arc<AppState>>) -> Result<Markup, AppError> {
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 {