use crate::abi::{ChangeOp, DataChange, DataField, DataSelect, Header, Str};
use crate::content::Guid;
use crate::{Load, Off};
mod items;
mod live;
mod recipes;
pub use items::{ItemTraits, Items, ItemsChange};
pub use live::{ChangeHandle, SetChangeError, Target};
pub use recipes::{RecipeTraits, Recipes, RecipesChange};
pub const MAX_FACTOR: f64 = 100.0;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Field {
MaxStackSize,
Output,
Cost,
Ingredient,
CraftingDuration,
Slots,
DamageMod,
SpeedMod,
Armor,
BurnTime,
EnergyOutput,
ComfortAmount,
StackSize,
Count,
SpawnProbability,
Weight,
Station,
}
impl Field {
pub const ALL: &[Self] = &[
Self::MaxStackSize,
Self::Output,
Self::Cost,
Self::Ingredient,
Self::CraftingDuration,
Self::Slots,
Self::DamageMod,
Self::SpeedMod,
Self::Armor,
Self::BurnTime,
Self::EnergyOutput,
Self::ComfortAmount,
Self::StackSize,
Self::Count,
Self::SpawnProbability,
Self::Weight,
Self::Station,
];
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::MaxStackSize => "max_stack",
Self::Output => "output_count",
Self::Cost => "cost",
Self::Ingredient => "ingredient",
Self::CraftingDuration => "time",
Self::Slots => "slots",
Self::DamageMod => "damage_mod",
Self::SpeedMod => "speed_mod",
Self::Armor => "armor",
Self::BurnTime => "burn_time",
Self::EnergyOutput => "energy_output",
Self::ComfortAmount => "comfort_amount",
Self::StackSize => "stack_size",
Self::Count => "count",
Self::SpawnProbability => "spawn_probability",
Self::Weight => "weight",
Self::Station => "station",
}
}
#[must_use]
pub const fn entry(self) -> Entry {
match self {
Self::MaxStackSize
| Self::Slots
| Self::DamageMod
| Self::SpeedMod
| Self::Armor
| Self::BurnTime
| Self::EnergyOutput
| Self::ComfortAmount => Entry::Item,
Self::StackSize | Self::Count | Self::SpawnProbability | Self::Weight => Entry::Loot,
Self::Output
| Self::Cost
| Self::Ingredient
| Self::CraftingDuration
| Self::Station => Entry::Recipe,
}
}
const fn range(self) -> (f64, f64) {
match self {
Self::MaxStackSize | Self::Output | Self::Cost | Self::StackSize => (1.0, 65535.0),
Self::Ingredient | Self::Count => (0.0, 65535.0),
Self::CraftingDuration | Self::BurnTime => (0.0, 86400.0),
Self::Slots => (8.0, 128.0),
Self::DamageMod => (0.0, 100.0),
Self::SpeedMod => (0.0, 10.0),
Self::Armor | Self::EnergyOutput | Self::Weight => (0.0, 1_000_000.0),
Self::SpawnProbability => (0.0, 1.0),
Self::ComfortAmount => (0.0, 1000.0),
Self::Station => (0.0, 4_294_967_295.0),
}
}
const fn step(self) -> f64 {
match self {
Self::Slots => 8.0,
_ => 1.0,
}
}
const fn whole(self) -> bool {
match self {
Self::MaxStackSize
| Self::Output
| Self::Cost
| Self::Ingredient
| Self::Slots
| Self::EnergyOutput
| Self::ComfortAmount
| Self::StackSize
| Self::Count
| Self::Station => true,
Self::CraftingDuration
| Self::DamageMod
| Self::SpeedMod
| Self::Armor
| Self::BurnTime
| Self::SpawnProbability
| Self::Weight => false,
}
}
pub fn check(self, value: Value) -> Result<(), ValueError> {
let (low, high) = self.range();
match value {
Value::Set(v) if !v.is_finite() || v < low || v > high => Err(ValueError::OutOfRange {
field: self,
value: v,
low,
high,
}),
Value::Set(_) if matches!(self, Self::Armor) => {
Err(ValueError::FactorOnly { field: self })
}
Value::Set(v) if self.step() > 1.0 && (v / self.step()).fract() != 0.0 => {
Err(ValueError::NotInSteps {
field: self,
value: v,
step: self.step(),
})
}
Value::Set(v) if self.whole() && v.fract() != 0.0 => Err(ValueError::NotWhole {
field: self,
value: v,
}),
Value::Times(_) if matches!(self, Self::Station) => {
Err(ValueError::SetOnly { field: self })
}
Value::Times(f) if matches!(self, Self::Slots) && f < 1.0 => {
Err(ValueError::Shrinks { field: self })
}
Value::Times(f) if !f.is_finite() || !(0.0..=MAX_FACTOR).contains(&f) => {
Err(ValueError::BadFactor { factor: f })
}
_ => Ok(()),
}
}
#[must_use]
pub fn resolve(self, base: f64, values: impl IntoIterator<Item = Value>) -> f64 {
let (mut set, mut factor) = (None, 1.0_f64);
for value in values {
match value {
Value::Set(v) => set = Some(v),
Value::Times(f) => factor *= f,
}
}
let start = set.unwrap_or(base);
let value = start * factor;
let value = if self.whole() {
(value / self.step()).round() * self.step()
} else {
value
};
let (low, high) = self.range();
let low = if self.whole() && start >= 1.0 && factor > 0.0 {
low.max(1.0)
} else {
low
};
value.clamp(low, high)
}
#[must_use]
pub const fn seen_by_clients(self) -> bool {
!matches!(self.entry(), Entry::Loot)
}
#[must_use]
pub const fn to_abi(self) -> DataField {
match self {
Self::MaxStackSize => DataField::MAX_STACK,
Self::Output => DataField::RECIPE_OUTPUT,
Self::Cost => DataField::RECIPE_COST,
Self::Ingredient => DataField::RECIPE_INGREDIENT,
Self::CraftingDuration => DataField::RECIPE_TIME,
Self::Slots => DataField::CONTAINER_SLOTS,
Self::DamageMod => DataField::DAMAGE_MOD,
Self::SpeedMod => DataField::SPEED_MOD,
Self::Armor => DataField::ARMOR,
Self::BurnTime => DataField::BURN_TIME,
Self::EnergyOutput => DataField::ENERGY_OUTPUT,
Self::ComfortAmount => DataField::COMFORT_AMOUNT,
Self::StackSize => DataField::LOOT_STACK_SIZE,
Self::Count => DataField::LOOT_COUNT,
Self::SpawnProbability => DataField::LOOT_SPAWN_PROBABILITY,
Self::Weight => DataField::LOOT_WEIGHT,
Self::Station => DataField::RECIPE_STATION,
}
}
#[must_use]
pub fn from_abi(field: DataField) -> Option<Self> {
Self::ALL.iter().copied().find(|f| f.to_abi() == field)
}
}
impl core::fmt::Display for Field {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.name())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Entry {
Item,
Recipe,
Loot,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum Value {
Set(f64),
Times(f64),
}
impl core::fmt::Display for Value {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Set(v) => write!(f, "{v}"),
Self::Times(v) => write!(f, "times {v}"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum ValueError {
#[error("{field} {value} is not from {low} to {high}")]
OutOfRange {
field: Field,
value: f64,
low: f64,
high: f64,
},
#[error("{field} {value} is no whole number")]
NotWhole {
field: Field,
value: f64,
},
#[error("factor {factor} is not from 0 to 100")]
BadFactor {
factor: f64,
},
#[error("{field} only grows: a chest a player upgrades moves every item it holds")]
Shrinks {
field: Field,
},
#[error("{field} takes only a set, never a factor")]
SetOnly {
field: Field,
},
#[error("{field} takes only a factor: {{ times = x }}")]
FactorOnly {
field: Field,
},
#[error("{field} {value} is not a multiple of {step}")]
NotInSteps {
field: Field,
value: f64,
step: f64,
},
#[error(
"{field} is no field a mod sets after init: its change alters a recipe's length or the menus built at start"
)]
NotLive {
field: Field,
},
#[error("{field} is no field a selection of items changes")]
NotSelectable {
field: Field,
},
#[error("{field} is no field a selection of recipes changes")]
NotRecipeSelectable {
field: Field,
},
#[error("{field} {}", if *named { "names no item" } else { "needs an item" })]
Item {
field: Field,
named: bool,
},
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Change {
pub entry: Guid,
pub field: Field,
pub item: Option<Guid>,
pub value: Value,
}
impl Change {
#[must_use]
pub const fn new(entry: Guid, field: Field, value: Value) -> Self {
Self {
entry,
field,
item: None,
value,
}
}
#[must_use]
pub const fn in_table(table: Guid, item: Guid, field: Field, value: Value) -> Self {
Self {
entry: table,
field,
item: Some(item),
value,
}
}
#[must_use]
pub fn station(recipe: Guid, workshop: crate::content::workshops::WorkshopId) -> Self {
Self::new(recipe, Field::Station, Value::Set(f64::from(workshop.id())))
}
#[must_use]
pub const fn ingredient(recipe: Guid, item: Guid, value: Value) -> Self {
Self {
entry: recipe,
field: Field::Ingredient,
item: Some(item),
value,
}
}
pub fn check(&self) -> Result<(), ValueError> {
let fill_table = matches!(
self.field,
Field::Count | Field::SpawnProbability | Field::Weight
);
let wants_item = self.field == Field::Ingredient || (fill_table && self.item.is_some());
if wants_item != self.item.is_some() {
return Err(ValueError::Item {
field: self.field,
named: self.item.is_some(),
});
}
self.field.check(self.value)
}
pub fn from_abi(raw: &DataChange) -> Result<Option<Self>, ValueError> {
if raw.select != DataSelect::ENTRY {
return Ok(None);
}
let Some((field, value)) = field_and_value(raw) else {
return Ok(None);
};
let change = Self {
entry: Guid(raw.entry),
field,
item: (raw.item != [0; 16]).then_some(Guid(raw.item)),
value,
};
change.check()?;
Ok(Some(change))
}
#[must_use]
fn to_abi(self, source: &str) -> DataChange {
let (op, value) = match self.value {
Value::Set(v) => (ChangeOp::SET, v),
Value::Times(v) => (ChangeOp::TIMES, v),
};
DataChange {
header: Header::of::<DataChange>(),
entry: self.entry.0,
field: self.field.to_abi(),
op,
value,
source: Str::new(source),
item: self.item.map_or([0; 16], |item| item.0),
select: DataSelect::ENTRY,
categories: 0,
}
}
}
fn field_and_value(raw: &DataChange) -> Option<(Field, Value)> {
let value = match raw.op {
ChangeOp::SET => Value::Set(raw.value),
ChangeOp::TIMES => Value::Times(raw.value),
_ => return None,
};
Some((Field::from_abi(raw.field)?, value))
}
impl Field {
pub(crate) fn unstable_gate(self) -> Result<(), Off> {
if cfg!(feature = "unstable") || !matches!(self, Self::MaxStackSize | Self::Slots) {
Ok(())
} else {
Err(Off::new(format!(
"a change of {self} needs the `unstable` feature"
)))
}
}
}
impl Load<'_> {
pub fn change(&mut self, change: &Change, source: &str) -> Result<(), Off> {
change.field.unstable_gate()?;
change
.check()
.map_err(|why| Off::new(format!("change of {}: {why}", change.entry)))?;
let raw = change.to_abi(source);
let api = self.host().api();
let status = (api.change_data)(self.host().handle(), &raw const raw);
crate::load::ok(status, (), || {
format!("change {} of {}", change.field, change.entry)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_last_set_wins_and_factors_multiply_whatever_their_order() {
let a = [
Value::Times(2.0),
Value::Set(10.0),
Value::Set(30.0),
Value::Times(3.0),
];
let b = [
Value::Set(10.0),
Value::Times(3.0),
Value::Set(30.0),
Value::Times(2.0),
];
assert_eq!(Field::MaxStackSize.resolve(5.0, a), 180.0);
assert_eq!(Field::MaxStackSize.resolve(5.0, b), 180.0);
}
#[test]
fn a_whole_field_rounds_half_away_from_zero_and_stays_in_range() {
assert_eq!(Field::MaxStackSize.resolve(5.0, [Value::Times(0.5)]), 3.0);
assert_eq!(
Field::MaxStackSize.resolve(60000.0, [Value::Times(2.0)]),
65535.0
);
assert_eq!(Field::MaxStackSize.resolve(1.0, [Value::Times(0.1)]), 1.0);
}
#[test]
fn no_value_keeps_the_base() {
assert_eq!(Field::MaxStackSize.resolve(20.0, []), 20.0);
}
#[test]
fn fractions_and_bad_factors_are_refused() {
assert!(Field::MaxStackSize.check(Value::Set(2.5)).is_err());
assert!(Field::MaxStackSize.check(Value::Times(f64::NAN)).is_err());
assert!(Field::MaxStackSize.check(Value::Times(101.0)).is_err());
assert!(Field::MaxStackSize.check(Value::Times(0.0)).is_ok());
}
#[test]
fn every_field_goes_through_the_abi_and_back() {
for &field in Field::ALL {
assert_eq!(Field::from_abi(field.to_abi()), Some(field));
}
}
#[test]
fn an_ingredient_goes_through_the_abi_and_back() {
let change = Change::ingredient(Guid([7; 16]), Guid([8; 16]), Value::Times(2.0));
let raw = change.to_abi("recipes.toml:3");
assert_eq!(Change::from_abi(&raw), Ok(Some(change)));
}
#[test]
fn a_field_without_an_item_refuses_one() {
let mut change = Change::new(Guid([7; 16]), Field::Output, Value::Set(2.0));
change.item = Some(Guid([8; 16]));
assert_eq!(
change.check().unwrap_err().to_string(),
"output_count names no item"
);
}
#[test]
fn a_count_of_one_or_more_stays_one_or_more_when_scaled_down() {
assert_eq!(Field::Ingredient.resolve(1.0, [Value::Times(0.1)]), 1.0);
assert_eq!(Field::Ingredient.resolve(3.0, [Value::Set(0.0)]), 0.0);
assert_eq!(Field::Ingredient.resolve(3.0, [Value::Times(0.0)]), 0.0);
assert_eq!(
Field::CraftingDuration.resolve(60.0, [Value::Times(0.25)]),
15.0
);
}
#[test]
fn slots_come_in_pages_of_eight() {
assert!(Field::Slots.check(Value::Set(48.0)).is_ok());
assert_eq!(
Field::Slots
.check(Value::Set(50.0))
.unwrap_err()
.to_string(),
"slots 50 is not a multiple of 8"
);
assert!(Field::Slots.check(Value::Set(136.0)).is_err());
assert_eq!(Field::Slots.resolve(24.0, [Value::Times(1.5)]), 40.0);
assert_eq!(Field::Slots.resolve(72.0, [Value::Times(2.0)]), 128.0);
assert!(Field::Slots.check(Value::Times(0.5)).is_err());
}
#[test]
fn armor_takes_only_a_factor() {
assert_eq!(
Field::Armor
.check(Value::Set(10.0))
.unwrap_err()
.to_string(),
"armor takes only a factor: { times = x }"
);
assert!(Field::Armor.check(Value::Times(1.5)).is_ok());
assert_eq!(Field::DamageMod.resolve(1.25, [Value::Times(2.0)]), 2.5);
}
#[test]
fn a_change_goes_through_the_abi_and_back() {
let change = Change::new(Guid([7; 16]), Field::MaxStackSize, Value::Set(50.0));
let raw = change.to_abi("items.toml:3");
assert_eq!(Change::from_abi(&raw), Ok(Some(change)));
}
}