use crate::abi::{RateId, Status};
use crate::content::recipes::RecipeKind;
use crate::{Host, Load, Off};
pub const MAX_FACTOR: f32 = 100.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ExperienceGroup {
Combat,
Mining,
Exploration,
}
impl ExperienceGroup {
#[must_use]
pub const fn of(source: crate::game_events::ExperienceSourceType) -> Option<Self> {
use crate::game_events::ExperienceSourceType as Source;
match source {
Source::Combat => Some(Self::Combat),
Source::Mining => Some(Self::Mining),
Source::Exploration | Source::Lore | Source::Quest | Source::ItemSetCompleted => {
Some(Self::Exploration)
}
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Rate {
Experience(ExperienceGroup),
WorkshopSpeed,
ResourceDrops,
Comfort,
SkillPoints,
Loot,
RecipeOutput(RecipeKind),
}
impl Rate {
const SINGLE: &[Self] = &[
Self::Experience(ExperienceGroup::Combat),
Self::Experience(ExperienceGroup::Mining),
Self::Experience(ExperienceGroup::Exploration),
Self::WorkshopSpeed,
Self::ResourceDrops,
Self::Comfort,
Self::SkillPoints,
Self::Loot,
];
pub fn all() -> impl Iterator<Item = Self> {
Self::SINGLE
.iter()
.copied()
.chain(RecipeKind::ALL.iter().map(|&kind| Self::RecipeOutput(kind)))
}
#[must_use]
pub const fn id(self) -> RateId {
match self {
Self::Experience(ExperienceGroup::Combat) => RateId::EXPERIENCE_COMBAT,
Self::Experience(ExperienceGroup::Mining) => RateId::EXPERIENCE_MINING,
Self::Experience(ExperienceGroup::Exploration) => RateId::EXPERIENCE_EXPLORATION,
Self::WorkshopSpeed => RateId::WORKSHOP_SPEED,
Self::ResourceDrops => RateId::RESOURCE_DROPS,
Self::Comfort => RateId::COMFORT,
Self::SkillPoints => RateId::SKILL_POINTS,
Self::Loot => RateId::LOOT,
Self::RecipeOutput(kind) => RateId::recipe_output(kind.code()),
}
}
#[must_use]
pub fn from_id(id: RateId) -> Option<Self> {
Self::all().find(|rate| rate.id() == id)
}
#[must_use]
pub const fn seen_by_clients(self) -> bool {
matches!(
self,
Self::WorkshopSpeed | Self::SkillPoints | Self::RecipeOutput(_)
)
}
}
#[must_use]
pub fn is_valid_factor(factor: f32) -> bool {
(0.0..=MAX_FACTOR).contains(&factor)
}
#[must_use]
#[expect(
clippy::cast_possible_truncation,
reason = "held to 0..=100 first, which an f32 holds"
)]
pub fn combine(factors: impl IntoIterator<Item = f32>) -> f32 {
let product = factors
.into_iter()
.fold(1.0_f64, |product, factor| product * f64::from(factor));
product.clamp(0.0, f64::from(MAX_FACTOR)) as f32
}
#[must_use]
#[expect(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
reason = "the game's whole numbers are far below 2^53, and the result is clamped first"
)]
pub fn scale_whole(value: i64, factor: f32) -> i64 {
let scaled = (value as f64 * f64::from(factor)).round();
scaled.clamp(i64::MIN as f64, i64::MAX as f64) as i64
}
#[derive(Clone, Copy, Debug, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum RateError {
#[error("factor {0} is not between 0 and 100")]
OutOfRange(f32),
#[error("the loader refused the factor")]
Refused,
}
#[derive(Clone, Copy, Debug)]
pub struct RateHandle {
host: Host,
rate: Rate,
}
impl RateHandle {
#[must_use]
pub const fn rate(&self) -> Rate {
self.rate
}
pub fn set(&self, factor: f32) -> Result<(), RateError> {
if !is_valid_factor(factor) {
return Err(RateError::OutOfRange(factor));
}
match (self.host.api().set_rate)(self.host.handle(), self.rate.id(), factor) {
Status::OK => Ok(()),
_ => Err(RateError::Refused),
}
}
}
impl Load<'_> {
pub fn rate(&mut self, rate: Rate) -> Result<RateHandle, Off> {
if rate.seen_by_clients() && !self.joins() {
return Err(Off::new(format!(
"the rate {rate:?}: clients count it too, so only a mod with `join: true` declares it"
)));
}
let host = self.host();
match (host.api().rate)(host.handle(), rate.id()) {
Status::OK => Ok(RateHandle { host, rate }),
status => Err(Off::status(&format!("the rate {rate:?}"), status)),
}
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
#[test]
fn every_recipe_kind_has_its_output_rate_and_ids_are_unique() {
let all: Vec<Rate> = Rate::all().collect();
for &kind in RecipeKind::ALL {
assert!(all.contains(&Rate::RecipeOutput(kind)), "{kind:?}");
}
for (i, a) in all.iter().enumerate() {
assert_eq!(Rate::from_id(a.id()), Some(*a));
assert!(all[i + 1..].iter().all(|b| b.id() != a.id()), "{a:?}");
}
}
proptest! {
#[test]
fn combine_is_the_same_in_any_order_and_stays_in_range(
factors in proptest::collection::vec(0.0f32..=MAX_FACTOR, 0..6),
) {
let forward = combine(factors.iter().copied());
let backward = combine(factors.iter().rev().copied());
prop_assert_eq!(forward.to_bits(), backward.to_bits());
prop_assert!((0.0..=MAX_FACTOR).contains(&forward));
}
#[test]
fn scale_whole_never_flips_the_sign(value in any::<i64>(), factor in 0.0f32..=MAX_FACTOR) {
let scaled = scale_whole(value, factor);
prop_assert!(scaled == 0 || scaled.signum() == value.signum());
}
}
}