449 lines
17 KiB
Rust
449 lines
17 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use chrono::{DateTime, Datelike, Duration, Timelike, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
clock,
|
|
forecast::{round_to_last_weather_time, Forecast, ForecastSet, Rate},
|
|
};
|
|
|
|
const DATA: &'static str = include_str!("../data.json");
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct Data {
|
|
pub db_data: SubData,
|
|
pub fish_entries: Vec<FishMeta>,
|
|
#[serde(skip)]
|
|
pub forecasts: ForecastSet,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct SubData {
|
|
#[serde(alias = "FISH")]
|
|
pub fish: HashMap<u32, FishEntry>,
|
|
#[serde(alias = "ZONES")]
|
|
pub zones: HashMap<u32, Zone>,
|
|
#[serde(alias = "WEATHER_RATES")]
|
|
pub weather_rates: HashMap<u32, WeatherRate>,
|
|
#[serde(alias = "FISHING_SPOTS")]
|
|
pub fishing_spots: HashMap<u32, FishingSpot>,
|
|
#[serde(alias = "WEATHER_TYPES")]
|
|
pub weather_types: HashMap<u32, WeatherType>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Default)]
|
|
#[serde(rename_all(deserialize = "camelCase"))]
|
|
pub struct FishEntry {
|
|
#[serde(alias = "_id")]
|
|
pub id: u32,
|
|
pub start_hour: Option<f32>,
|
|
pub end_hour: Option<f32>,
|
|
pub location: Option<u32>,
|
|
pub best_catch_path: Vec<u32>,
|
|
pub predators: Vec<Vec<u32>>,
|
|
pub intuition_length: Option<u32>,
|
|
pub patch: f32,
|
|
pub folklore: Option<u32>,
|
|
pub fish_eyes: bool,
|
|
pub big_fish: bool,
|
|
pub weather_set: Vec<u32>,
|
|
pub previous_weather_set: Vec<u32>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct Zone {
|
|
pub name_en: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct FishingSpot {
|
|
#[serde(alias = "_id")]
|
|
pub id: u32,
|
|
pub name_en: String,
|
|
#[serde(alias = "territory_id")] // not sure why it's named this, but thanks serde!
|
|
pub forecast_id: u32,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct WeatherRate {
|
|
pub zone_id: u32,
|
|
pub weather_rates: Vec<Vec<u32>>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct WeatherType {
|
|
pub name_en: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
pub struct Location {
|
|
pub id: u32,
|
|
pub name_en: String,
|
|
pub map_coords: (f32, f32, u32),
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Default)]
|
|
pub struct FishMeta {
|
|
pub id: u32,
|
|
pub name_en: String,
|
|
}
|
|
|
|
pub struct CombinedFish<'a> {
|
|
pub entry: &'a FishEntry,
|
|
pub meta: &'a FishMeta,
|
|
pub is_up: bool,
|
|
pub is_always_up: bool,
|
|
pub windows: Vec<Window>,
|
|
pub rarity: f32,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Window {
|
|
pub start_time: DateTime<Utc>,
|
|
pub duration: Duration,
|
|
}
|
|
|
|
impl<'a> CombinedFish<'a> {
|
|
/// Fills in the rest of the struct.
|
|
pub fn find_uptime(&mut self, data: &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).
|
|
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::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;
|
|
let cur_hour = et.hour() as f32 + et.minute() as f32 / 60.;
|
|
if spans_midnight {
|
|
return cur_hour > start_hour || cur_hour < end_hour;
|
|
} else {
|
|
return start_hour < cur_hour && cur_hour < end_hour;
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
// Find the associated fishing spot.
|
|
let spot = data
|
|
.db_data
|
|
.fishing_spots
|
|
.get(&self.entry.location.unwrap())
|
|
.unwrap();
|
|
data.forecasts.get(&spot.forecast_id)
|
|
}
|
|
|
|
/// 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_at(date);
|
|
let is_current = self
|
|
.entry
|
|
.weather_set
|
|
.iter()
|
|
.any(|ws| ws == &cur_weather.weather_id);
|
|
|
|
// Check if the fish depends on a previous weather, and if so, if that weather is
|
|
// right as well.
|
|
let is_past = if self.entry.previous_weather_set.is_empty() {
|
|
true
|
|
} else {
|
|
let prev_date = date - Duration::hours(8);
|
|
let prev_weather = forecast.weather_at(prev_date);
|
|
self.entry
|
|
.previous_weather_set
|
|
.iter()
|
|
.any(|ws| ws == &prev_weather.weather_id)
|
|
};
|
|
|
|
is_current && is_past
|
|
}
|
|
|
|
/// 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 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
|
|
}
|
|
|
|
// 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<Utc>) -> 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<Window> {
|
|
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() {
|
|
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();
|
|
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);
|
|
}
|
|
}
|
|
|
|
if self.meta.name_en == "The Drowned Sniper" {
|
|
dbg!(&results[1]);
|
|
dbg!(clock::to_earth_time(results[1].start_time));
|
|
}
|
|
results
|
|
}
|
|
}
|
|
|
|
impl Data {
|
|
pub fn new() -> Self {
|
|
let json = &mut serde_json::Deserializer::from_str(DATA);
|
|
let mut data: Self = serde_path_to_error::deserialize(json).unwrap();
|
|
data.forecasts = data
|
|
.db_data
|
|
.weather_rates
|
|
.iter()
|
|
.map(|(id, wr)| {
|
|
(
|
|
id.clone(),
|
|
Forecast {
|
|
zone_id: wr.zone_id,
|
|
rates: wr
|
|
.weather_rates
|
|
.iter()
|
|
.map(|rate| Rate {
|
|
weather_id: rate[0],
|
|
rate: rate[1],
|
|
})
|
|
.collect(),
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
data
|
|
}
|
|
|
|
pub fn fish_with_meta(&self) -> HashMap<u32, CombinedFish> {
|
|
self.db_data
|
|
.fish
|
|
.iter()
|
|
.filter_map(|(k, v)| {
|
|
let corresponding_meta = self.fish_entries.iter().find(|m| &m.id == k);
|
|
corresponding_meta.map(|m| {
|
|
let mut cfish = CombinedFish {
|
|
entry: v,
|
|
meta: m,
|
|
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)
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
pub fn get_weather_name(data: &Data, id: u32) -> &str {
|
|
&data.db_data.weather_types.get(&id).unwrap().name_en
|
|
}
|
|
|
|
pub fn get_zone_name(data: &Data, id: u32) -> &str {
|
|
&data.db_data.zones.get(&id).unwrap().name_en
|
|
}
|