From 7686f6972bc15ec5ec60cca6de45ff5d2e8efd85 Mon Sep 17 00:00:00 2001 From: Surflurer <22912139+Surflurer@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:03:08 +0800 Subject: [PATCH] Accept floating-point external consumption rate --- crates/end_io/src/aic.rs | 22 ++++- crates/end_io/src/schema.rs | 89 +++++++++++++++++-- crates/end_io/tests/aic_validation.rs | 4 +- crates/end_model/src/aic_input/mod.rs | 2 +- .../end_model/src/aic_input/model/builder.rs | 10 +-- crates/end_model/src/aic_input/model/mod.rs | 6 +- crates/end_model/src/aic_input/model/types.rs | 76 +++++++++++++++- crates/end_model/src/aic_input/tests.rs | 21 +++-- crates/end_model/src/lib.rs | 2 +- crates/end_opt/src/logistics.rs | 16 ++-- crates/end_opt/src/solver.rs | 6 +- crates/end_opt/tests/two_stage_regression.rs | 14 +-- crates/end_report/tests/report_render.rs | 6 +- .../components/workbench/EditorPanel.svelte | 2 + web/src/lib/aic.test.ts | 29 ++++++ web/src/lib/aic.ts | 16 ++-- web/src/lib/draft-actions.test.ts | 6 +- web/src/lib/draft-actions.ts | 4 +- web/src/lib/draft-storage.ts | 4 +- 19 files changed, 269 insertions(+), 66 deletions(-) diff --git a/crates/end_io/src/aic.rs b/crates/end_io/src/aic.rs index 0cebd43..ef20ca0 100644 --- a/crates/end_io/src/aic.rs +++ b/crates/end_io/src/aic.rs @@ -2,7 +2,7 @@ use crate::error::map_aic_build_error; use crate::schema::{AicToml, PowerToml}; use crate::{Error, Result}; use end_model::{ - AicInputs, Catalog, ItemNonZeroU32Map, ItemU32Map, OutpostInput, PowerConfig, Stage2Weights, + AicInputs, Catalog, ItemPosF64Map, ItemU32Map, OutpostInput, PosF64, PowerConfig, Stage2Weights, }; use generativity::{Guard, make_guard}; use std::collections::BTreeMap; @@ -100,10 +100,18 @@ fn resolve_aic<'cid, 'sid>( let supply_per_min_span = raw.supply_per_min.span(); let raw_supply_per_min = raw.supply_per_min.into_inner(); - let mut supply_per_min = ItemNonZeroU32Map::with_capacity(raw_supply_per_min.len()); + let mut supply_per_min = ItemPosF64Map::with_capacity(raw_supply_per_min.len()); for (item_key, value) in raw_supply_per_min { let item_key = item_key.into_inner(); let value = value.into_inner(); + let value = PosF64::new(value).ok_or_else(|| Error::Schema { + path: path.clone(), + field: "supply_per_min", + index: None, + span: Some(supply_per_min_span.clone()), + src: Some(Arc::clone(&src)), + message: format!("must be > 0, got {value}").into_boxed_str(), + })?; let item = catalog .item_id(item_key.as_str()) .ok_or_else(|| Error::UnknownItem { @@ -118,10 +126,18 @@ fn resolve_aic<'cid, 'sid>( let external_consumption_per_min_span = raw.external_consumption_per_min.span(); let raw_external_consumption_per_min = raw.external_consumption_per_min.into_inner(); let mut external_consumption_per_min = - ItemNonZeroU32Map::with_capacity(raw_external_consumption_per_min.len()); + ItemPosF64Map::with_capacity(raw_external_consumption_per_min.len()); for (item_key, value) in raw_external_consumption_per_min { let item_key = item_key.into_inner(); let value = value.into_inner(); + let value = PosF64::new(value).ok_or_else(|| Error::Schema { + path: path.clone(), + field: "external_consumption_per_min", + index: None, + span: Some(external_consumption_per_min_span.clone()), + src: Some(Arc::clone(&src)), + message: format!("must be > 0, got {value}").into_boxed_str(), + })?; let item = catalog .item_id(item_key.as_str()) .ok_or_else(|| Error::UnknownItem { diff --git a/crates/end_io/src/schema.rs b/crates/end_io/src/schema.rs index 9a40fc5..3b8cc5e 100644 --- a/crates/end_io/src/schema.rs +++ b/crates/end_io/src/schema.rs @@ -1,6 +1,7 @@ use end_model::{DisplayName, Key, Region}; use serde::Deserialize; use serde::de::Error as _; +use serde::de::{Visitor, Unexpected}; use std::collections::BTreeMap; use std::num::NonZeroU32; use toml::Spanned; @@ -120,10 +121,10 @@ pub(crate) struct AicToml { pub(crate) power: PowerToml, #[serde(default)] pub(crate) objective: ObjectiveToml, - #[serde(default = "default_empty_spanned_item_positive_u32_map")] - pub(crate) supply_per_min: Spanned>, - #[serde(default = "default_empty_spanned_item_positive_u32_map")] - pub(crate) external_consumption_per_min: Spanned>, + #[serde(default = "default_empty_spanned_item_positive_f64_map")] + pub(crate) supply_per_min: Spanned>, + #[serde(default = "default_empty_spanned_item_positive_f64_map")] + pub(crate) external_consumption_per_min: Spanned>, #[serde(default)] pub(crate) outposts: Box<[Spanned]>, } @@ -305,6 +306,72 @@ impl<'de> Deserialize<'de> for PositiveU32Toml { } } +#[derive(Debug, Clone, Copy)] +pub(crate) struct PositiveF64Toml(f64); + +impl PositiveF64Toml { + pub(crate) fn into_inner(self) -> f64 { + self.0 + } +} + +impl<'de> Deserialize<'de> for PositiveF64Toml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct PositiveF64Visitor; + + impl<'de> Visitor<'de> for PositiveF64Visitor { + type Value = PositiveF64Toml; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a positive number") + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + parse_positive_f64(value as f64).map(PositiveF64Toml).map_err(E::custom) + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + parse_positive_f64(value as f64).map(PositiveF64Toml).map_err(E::custom) + } + + fn visit_f64(self, value: f64) -> Result + where + E: serde::de::Error, + { + parse_positive_f64(value).map(PositiveF64Toml).map_err(E::custom) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + let parsed = value.parse::().map_err(|_| { + E::invalid_value(Unexpected::Str(value), &"a positive number") + })?; + parse_positive_f64(parsed).map(PositiveF64Toml).map_err(E::custom) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + self.visit_str(&value) + } + } + + deserializer.deserialize_any(PositiveF64Visitor) + } +} + #[derive(Debug, Clone, Copy)] pub(crate) struct NonNegativeU32Toml(u32); @@ -456,6 +523,16 @@ fn parse_non_negative_f64(value: f64) -> Result { Ok(value) } +fn parse_positive_f64(value: f64) -> Result { + if !value.is_finite() { + return Err("must be a finite number".to_string()); + } + if value <= 0.0 { + return Err(format!("must be > 0, got {value}")); + } + Ok(value) +} + fn parse_supported_aic_version(value: i64) -> Result { let parsed = parse_non_negative_u32(value)?; match parsed { @@ -464,6 +541,6 @@ fn parse_supported_aic_version(value: i64) -> Result { } } -fn default_empty_spanned_item_positive_u32_map() -> Spanned> { +fn default_empty_spanned_item_positive_f64_map() -> Spanned> { Spanned::new(0..0, BTreeMap::new()) -} \ No newline at end of file +} diff --git a/crates/end_io/tests/aic_validation.rs b/crates/end_io/tests/aic_validation.rs index 0e91eff..2187145 100644 --- a/crates/end_io/tests/aic_validation.rs +++ b/crates/end_io/tests/aic_validation.rs @@ -162,7 +162,7 @@ version = 2 ); let err = load_aic_from_str(&src, &catalog, aic_guard).expect_err("zero supply should fail"); - assert_toml_parse_with_span(&err, "aic.toml", "must be >= 1, got 0"); + assert_toml_parse_with_span(&err, "aic.toml", "must be > 0, got 0"); } #[test] @@ -182,7 +182,7 @@ version = 2 let err = load_aic_from_str(&src, &catalog, aic_guard).expect_err("zero consumption should fail"); - assert_toml_parse_with_span(&err, "aic.toml", "must be >= 1, got 0"); + assert_toml_parse_with_span(&err, "aic.toml", "must be > 0, got 0"); } #[test] diff --git a/crates/end_model/src/aic_input/mod.rs b/crates/end_model/src/aic_input/mod.rs index 90dc4a1..f235e12 100644 --- a/crates/end_model/src/aic_input/mod.rs +++ b/crates/end_model/src/aic_input/mod.rs @@ -1,7 +1,7 @@ mod model; pub use model::{ - AicBuildError, AicInputs, AicInputsBuilder, ItemNonZeroU32Map, ItemU32Map, OutpostId, + AicBuildError, AicInputs, AicInputsBuilder, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights, }; diff --git a/crates/end_model/src/aic_input/model/builder.rs b/crates/end_model/src/aic_input/model/builder.rs index 9524102..c4093b4 100644 --- a/crates/end_model/src/aic_input/model/builder.rs +++ b/crates/end_model/src/aic_input/model/builder.rs @@ -3,15 +3,15 @@ use std::collections::HashSet; use generativity::Guard; use super::{ - AicBuildError, AicInputs, ItemNonZeroU32Map, OutpostId, OutpostInput, PowerConfig, Region, + AicBuildError, AicInputs, ItemPosF64Map, OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights, }; #[derive(Debug)] pub struct AicInputsBuilder<'cid, 'sid> { region: Region, - supply_per_min: ItemNonZeroU32Map<'cid>, - external_consumption_per_min: ItemNonZeroU32Map<'cid>, + supply_per_min: ItemPosF64Map<'cid>, + external_consumption_per_min: ItemPosF64Map<'cid>, outposts: Vec>, outpost_keys: HashSet, power_config: PowerConfig, @@ -23,8 +23,8 @@ impl<'cid, 'sid> AicInputs<'cid, 'sid> { pub fn builder( guard: Guard<'sid>, power_config: PowerConfig, - supply_per_min: ItemNonZeroU32Map<'cid>, - external_consumption_per_min: ItemNonZeroU32Map<'cid>, + supply_per_min: ItemPosF64Map<'cid>, + external_consumption_per_min: ItemPosF64Map<'cid>, ) -> AicInputsBuilder<'cid, 'sid> { AicInputsBuilder { region: Region::FourthValley, diff --git a/crates/end_model/src/aic_input/model/mod.rs b/crates/end_model/src/aic_input/model/mod.rs index 1c66a71..bee9072 100644 --- a/crates/end_model/src/aic_input/model/mod.rs +++ b/crates/end_model/src/aic_input/model/mod.rs @@ -3,7 +3,7 @@ mod types; pub use builder::AicInputsBuilder; pub use types::{ - AicBuildError, AicInputs, ItemNonZeroU32Map, ItemU32Map, OutpostId, OutpostInput, PowerConfig, + AicBuildError, AicInputs, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights, }; @@ -20,11 +20,11 @@ impl<'cid, 'sid> AicInputs<'cid, 'sid> { self.region } - pub fn supply_per_min(&self) -> &ItemNonZeroU32Map<'cid> { + pub fn supply_per_min(&self) -> &ItemPosF64Map<'cid> { &self.supply_per_min } - pub fn external_consumption_per_min(&self) -> &ItemNonZeroU32Map<'cid> { + pub fn external_consumption_per_min(&self) -> &ItemPosF64Map<'cid> { &self.external_consumption_per_min } diff --git a/crates/end_model/src/aic_input/model/types.rs b/crates/end_model/src/aic_input/model/types.rs index e11b2c4..5ed8288 100644 --- a/crates/end_model/src/aic_input/model/types.rs +++ b/crates/end_model/src/aic_input/model/types.rs @@ -5,7 +5,7 @@ use generativity::Id; use thiserror::Error; use vector_map::VecMap; -use crate::{DisplayName, ItemId, Key}; +use crate::{DisplayName, ItemId, Key, PosF64}; #[derive(Debug, Clone, Copy, PartialEq)] pub struct Stage2Weights { @@ -233,6 +233,76 @@ impl<'id> IntoIterator for ItemNonZeroU32Map<'id> { } } +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ItemPosF64Map<'id>(VecMap, PosF64>); + +impl<'id> ItemPosF64Map<'id> { + pub fn new() -> Self { + Self(VecMap::new()) + } + + pub fn with_capacity(capacity: usize) -> Self { + Self(VecMap::with_capacity(capacity)) + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn insert(&mut self, item: ItemId<'id>, value: PosF64) -> Option { + self.0.insert(item, value) + } + + pub fn get(&self, item: ItemId<'id>) -> Option<&PosF64> { + self.0.get(&item) + } + + pub fn iter(&self) -> impl Iterator, PosF64)> + '_ { + self.0.iter().map(|(item, value)| (*item, *value)) + } +} + +impl<'id> Extend<(ItemId<'id>, PosF64)> for ItemPosF64Map<'id> { + fn extend, PosF64)>>(&mut self, iter: T) { + for (item, value) in iter { + self.insert(item, value); + } + } +} + +impl<'id> FromIterator<(ItemId<'id>, PosF64)> for ItemPosF64Map<'id> { + fn from_iter, PosF64)>>(iter: T) -> Self { + let mut map = Self::new(); + map.extend(iter); + map + } +} + +impl<'id, const N: usize> From<[(ItemId<'id>, PosF64); N]> for ItemPosF64Map<'id> { + fn from(value: [(ItemId<'id>, PosF64); N]) -> Self { + value.into_iter().collect() + } +} + +impl<'id> From, PosF64)>> for ItemPosF64Map<'id> { + fn from(value: Vec<(ItemId<'id>, PosF64)>) -> Self { + value.into_iter().collect() + } +} + +impl<'id> IntoIterator for ItemPosF64Map<'id> { + type Item = (ItemId<'id>, PosF64); + type IntoIter = vector_map::IntoIter, PosF64>; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + #[derive(Debug, Clone)] pub struct OutpostInput<'id> { pub key: Key, @@ -245,8 +315,8 @@ pub struct OutpostInput<'id> { #[derive(Debug, Clone)] pub struct AicInputs<'cid, 'sid> { pub(super) region: Region, - pub(super) supply_per_min: ItemNonZeroU32Map<'cid>, - pub(super) external_consumption_per_min: ItemNonZeroU32Map<'cid>, + pub(super) supply_per_min: ItemPosF64Map<'cid>, + pub(super) external_consumption_per_min: ItemPosF64Map<'cid>, pub(super) outposts: Box<[OutpostInput<'cid>]>, pub(super) power_config: PowerConfig, pub(super) stage2_weights: Stage2Weights, diff --git a/crates/end_model/src/aic_input/tests.rs b/crates/end_model/src/aic_input/tests.rs index 67beece..e7fc23e 100644 --- a/crates/end_model/src/aic_input/tests.rs +++ b/crates/end_model/src/aic_input/tests.rs @@ -3,10 +3,9 @@ use generativity::make_guard; use crate::{ - AicBuildError, Catalog, DisplayName, ItemDef, ItemNonZeroU32Map, ItemU32Map, Key, OutpostInput, - PowerConfig, ThermalBankDef, + AicBuildError, Catalog, DisplayName, ItemDef, ItemPosF64Map, ItemU32Map, Key, OutpostInput, + PosF64, PowerConfig, ThermalBankDef, }; -use std::num::NonZeroU32; fn key(value: &str) -> Key { value.try_into().expect("valid key") @@ -71,19 +70,19 @@ fn item_u32_map_from_vec_uses_last_value_for_duplicates() { } #[test] -fn item_non_zero_u32_map_from_vec_uses_last_value_for_duplicates() { +fn item_pos_f64_map_from_vec_uses_last_value_for_duplicates() { make_guard!(guard); let (_, a, b) = sample_catalog(guard); - let map: ItemNonZeroU32Map = vec![ - (a, NonZeroU32::new(1).expect("non-zero")), - (b, NonZeroU32::new(3).expect("non-zero")), - (a, NonZeroU32::new(2).expect("non-zero")), + let map: ItemPosF64Map = vec![ + (a, PosF64::new(1.0).expect("positive")), + (b, PosF64::new(3.0).expect("positive")), + (a, PosF64::new(2.0).expect("positive")), ] .into(); assert_eq!(map.len(), 2); - assert_eq!(map.get(a), Some(&NonZeroU32::new(2).expect("non-zero"))); - assert_eq!(map.get(b), Some(&NonZeroU32::new(3).expect("non-zero"))); + assert_eq!(map.get(a), Some(&PosF64::new(2.0).expect("positive"))); + assert_eq!(map.get(b), Some(&PosF64::new(3.0).expect("positive"))); } #[test] @@ -95,7 +94,7 @@ fn aic_parse_rejects_duplicate_outpost_keys() { let mut builder = crate::AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(b, NonZeroU32::new(1).expect("non-zero"))].into(), + vec![(b, PosF64::new(1.0).expect("positive"))].into(), Default::default(), ); diff --git a/crates/end_model/src/lib.rs b/crates/end_model/src/lib.rs index f1af151..56ea5cd 100644 --- a/crates/end_model/src/lib.rs +++ b/crates/end_model/src/lib.rs @@ -5,7 +5,7 @@ mod optimization; mod text; pub use aic_input::{ - AicBuildError, AicInputs, AicInputsBuilder, ItemNonZeroU32Map, ItemU32Map, OutpostId, + AicBuildError, AicInputs, AicInputsBuilder, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights, }; pub use catalog::{ diff --git a/crates/end_opt/src/logistics.rs b/crates/end_opt/src/logistics.rs index 49b9bcf..d99731d 100644 --- a/crates/end_opt/src/logistics.rs +++ b/crates/end_opt/src/logistics.rs @@ -41,7 +41,7 @@ pub fn build_item_subproblems<'cid, 'sid, 'rid>( &mut per_item, item, SupplySite::ExternalSupply { item }, - supply.get() as f64, + supply.get(), "external_supply", )?; } @@ -56,7 +56,7 @@ pub fn build_item_subproblems<'cid, 'sid, 'rid>( &mut per_item, item, DemandSite::ExternalConsumption { item }, - consumption.get() as f64, + consumption.get(), "external_consumption", )?; } @@ -934,7 +934,7 @@ mod tests { let mut aic_builder = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(ore, nz(20))].into(), + vec![(ore, PosF64::new(20.0).expect("positive"))].into(), Default::default(), ); aic_builder @@ -1021,8 +1021,8 @@ mod tests { let mut aic_builder = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(item, nz(10))].into(), - vec![(item, nz(4))].into(), + vec![(item, PosF64::new(10.0).expect("positive"))].into(), + vec![(item, PosF64::new(4.0).expect("positive"))].into(), ); aic_builder .add_outpost(OutpostInput { @@ -1098,7 +1098,11 @@ mod tests { let mut aic_builder = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(ore, nz(10)), (ingot, nz(7))].into(), + vec![ + (ore, PosF64::new(10.0).expect("positive")), + (ingot, PosF64::new(7.0).expect("positive")), + ] + .into(), Default::default(), ); aic_builder diff --git a/crates/end_opt/src/solver.rs b/crates/end_opt/src/solver.rs index 6c562c9..8c8edfd 100644 --- a/crates/end_opt/src/solver.rs +++ b/crates/end_opt/src/solver.rs @@ -367,7 +367,7 @@ fn solve_stage<'cid, 'sid>( let item_balance = aic.supply_per_min().iter().fold( ItemVec::filled(catalog, Expression::from(0.0)), |mut item_balance, (item, supply)| { - item_balance[item] = Expression::from(supply.get() as f64); + item_balance[item] = Expression::from(supply.get()); item_balance }, ); @@ -375,7 +375,7 @@ fn solve_stage<'cid, 'sid>( let item_balance = aic.external_consumption_per_min().iter().fold( item_balance, |mut item_balance, (item, consume)| { - item_balance[item] -= consume.get() as f64; + item_balance[item] -= consume.get(); item_balance }, ); @@ -648,7 +648,7 @@ fn solve_stage<'cid, 'sid>( ExternalSupplySlack { item, slack_per_min: solution.eval(expr), - supply_per_min: supply.get() as f64, + supply_per_min: supply.get(), } }) .collect::>(); diff --git a/crates/end_opt/tests/two_stage_regression.rs b/crates/end_opt/tests/two_stage_regression.rs index 078d227..52fc180 100644 --- a/crates/end_opt/tests/two_stage_regression.rs +++ b/crates/end_opt/tests/two_stage_regression.rs @@ -2,7 +2,7 @@ use end_model::{ AicInputs, Catalog, DisplayName, FacilityDef, FacilityRegions, ItemDef, Key, OutpostInput, - PowerConfig, Region, Stack, Stage2Weights, ThermalBankDef, + PosF64, PowerConfig, Region, Stack, Stage2Weights, ThermalBankDef, }; use end_opt::{Error, NEAR_INT_EPS, run_two_stage}; use generativity::Guard; @@ -153,7 +153,7 @@ fn run_two_stage_applies_region_facility_restrictions() { let mut aic_builder = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(ore, nz(10))].into(), + vec![(ore, PosF64::new(10.0).expect("positive"))].into(), Default::default(), ) .region(Region::FourthValley); @@ -191,7 +191,7 @@ fn sample_catalog_and_aic<'cid, 'sid>( let mut aic_builder = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(ore, nz(10))].into(), + vec![(ore, PosF64::new(10.0).expect("positive"))].into(), Default::default(), ); aic_builder @@ -216,7 +216,7 @@ fn run_two_stage_allows_empty_recipes_with_direct_external_sales() { let mut aic_builder = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(ore, nz(10))].into(), + vec![(ore, PosF64::new(10.0).expect("positive"))].into(), Default::default(), ); aic_builder @@ -327,7 +327,7 @@ fn max_money_slack_does_not_virtualize_fluid_surplus_into_stockpile() { let mut aic_builder = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(ore, nz(10))].into(), + vec![(ore, PosF64::new(10.0).expect("positive"))].into(), Default::default(), ) .stage2_weights(Stage2Weights { @@ -417,8 +417,8 @@ fn run_two_stage_rejects_infeasible_external_consumption() { let aic = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(ore, nz(10))].into(), - vec![(ore, nz(11))].into(), + vec![(ore, PosF64::new(10.0).expect("positive"))].into(), + vec![(ore, PosF64::new(11.0).expect("positive"))].into(), ) .build(); diff --git a/crates/end_report/tests/report_render.rs b/crates/end_report/tests/report_render.rs index 56c384f..00b7c94 100644 --- a/crates/end_report/tests/report_render.rs +++ b/crates/end_report/tests/report_render.rs @@ -2,7 +2,7 @@ use end_model::{ AicInputs, Catalog, DisplayName, FacilityDef, FacilityRegions, ItemDef, Key, OutpostInput, - PowerConfig, Stack, ThermalBankDef, + PosF64, PowerConfig, Stack, ThermalBankDef, }; use end_opt::run_two_stage; use end_report::{Lang, build_report}; @@ -87,8 +87,8 @@ fn sample_catalog_and_inputs<'cid, 'sid, 'rid>( let mut aic_builder = AicInputs::builder( aic_guard, PowerConfig::default(), - vec![(ore, nz(10))].into(), - vec![(ore, nz(1))].into(), + vec![(ore, PosF64::new(10.0).expect("positive"))].into(), + vec![(ore, PosF64::new(1.0).expect("positive"))].into(), ); aic_builder .add_outpost(OutpostInput { diff --git a/web/src/components/workbench/EditorPanel.svelte b/web/src/components/workbench/EditorPanel.svelte index 36719fd..f1591b5 100644 --- a/web/src/components/workbench/EditorPanel.svelte +++ b/web/src/components/workbench/EditorPanel.svelte @@ -401,6 +401,7 @@ actions.supply.setValue( @@ -460,6 +461,7 @@ actions.consumption.setValue( diff --git a/web/src/lib/aic.test.ts b/web/src/lib/aic.test.ts index 8580361..f96d6f8 100644 --- a/web/src/lib/aic.test.ts +++ b/web/src/lib/aic.test.ts @@ -54,6 +54,35 @@ describe('aic toml conversions', () => { expect(parsed.outposts[0]?.prices).toHaveLength(2); }); + it('preserves fractional supply/consumption flows when building and parsing TOML', () => { + const draft: AicDraft = { + region: 'wuling', + power: { + enabled: true, + externalProductionW: 200, + externalConsumptionW: 0 + }, + objective: { + minMachines: 0, + maxPowerSlack: 0, + maxMoneySlack: 0 + }, + supply: [ + { itemKey: 'IronOre', value: 1.5 }, + { itemKey: 'CopperOre', value: 0.25 } + ], + consumption: [{ itemKey: 'Water', value: 2.75 }], + outposts: [] + }; + + const toml = buildAicToml(draft); + const parsed = parseAicToml(toml); + + expect(parsed.supply.find((row) => row.itemKey === 'IronOre')?.value).toBeCloseTo(1.5); + expect(parsed.supply.find((row) => row.itemKey === 'CopperOre')?.value).toBeCloseTo(0.25); + expect(parsed.consumption.find((row) => row.itemKey === 'Water')?.value).toBeCloseTo(2.75); + }); + it('parses legacy en/zh outpost names into unified name field', () => { const toml = `version = 2 region = "wuling" diff --git a/web/src/lib/aic.ts b/web/src/lib/aic.ts index 0b25f1f..282f3ab 100644 --- a/web/src/lib/aic.ts +++ b/web/src/lib/aic.ts @@ -22,7 +22,7 @@ function parseItemFlowRows(map: Record): { itemKey: string; val return Object.entries(map) .map(([itemKey, value]) => ({ itemKey, - value: asInt(value) + value: asNonNegativeNumber(value, 0) })) .filter((row) => row.itemKey.trim().length > 0) .sort((a, b) => a.itemKey.localeCompare(b.itemKey)); @@ -133,10 +133,16 @@ function cleanDraft(draft: AicDraft): AicDraft { }, supply: draft.supply .filter((row) => row.itemKey.trim().length > 0) - .map((row) => ({ itemKey: row.itemKey.trim(), value: asInt(row.value) })), + .map((row) => ({ + itemKey: row.itemKey.trim(), + value: asNonNegativeNumber(row.value, 0), + })), consumption: draft.consumption .filter((row) => row.itemKey.trim().length > 0) - .map((row) => ({ itemKey: row.itemKey.trim(), value: asInt(row.value) })), + .map((row) => ({ + itemKey: row.itemKey.trim(), + value: asNonNegativeNumber(row.value, 0), + })), outposts: draft.outposts .filter((outpost) => outpost.key.trim().length > 0) .map((outpost) => ({ @@ -168,12 +174,12 @@ export function buildAicToml(draft: AicDraft): string { const supplyPerMin = Object.fromEntries( cleaned.supply .filter((row) => row.value > 0) - .map((row) => [row.itemKey, asInt(row.value)]) + .map((row) => [row.itemKey, asNonNegativeNumber(row.value, 0)]) ); const externalConsumptionPerMin = Object.fromEntries( cleaned.consumption .filter((row) => row.value > 0) - .map((row) => [row.itemKey, asInt(row.value)]) + .map((row) => [row.itemKey, asNonNegativeNumber(row.value, 0)]) ); const outposts = cleaned.outposts.map((outpost) => { diff --git a/web/src/lib/draft-actions.test.ts b/web/src/lib/draft-actions.test.ts index 39318fd..5079904 100644 --- a/web/src/lib/draft-actions.test.ts +++ b/web/src/lib/draft-actions.test.ts @@ -38,7 +38,7 @@ const EMPTY_DRAFT: AicDraft = { }; describe('draft actions', () => { - it('normalizes numeric inputs as non-negative integers', () => { + it('normalizes power/price as non-negative integers while keeping flows as non-negative numbers', () => { let draft = setPowerExternalConsumption(EMPTY_DRAFT, -12.3); draft = setPowerExternalProduction(draft, 66.2); draft = addSupplyRow(draft, 'IronOre'); @@ -48,8 +48,8 @@ describe('draft actions', () => { expect(draft.power.externalConsumptionW).toBe(0); expect(draft.power.externalProductionW).toBe(66); - expect(draft.supply[0]?.value).toBe(5); - expect(draft.consumption[0]?.value).toBe(7); + expect(draft.supply[0]?.value).toBe(4.8); + expect(draft.consumption[0]?.value).toBe(6.7); }); it('adds and removes outposts while maintaining selected index', () => { diff --git a/web/src/lib/draft-actions.ts b/web/src/lib/draft-actions.ts index faf29cf..2c7f1df 100644 --- a/web/src/lib/draft-actions.ts +++ b/web/src/lib/draft-actions.ts @@ -100,7 +100,7 @@ export function setSupplyValue(draft: AicDraft, index: number, value: number): A rowIndex === index ? { ...row, - value: asNonNegativeInt(value) + value: asNonNegativeNumber(value) } : row ) @@ -128,7 +128,7 @@ export function setConsumptionValue(draft: AicDraft, index: number, value: numbe rowIndex === index ? { ...row, - value: asNonNegativeInt(value) + value: asNonNegativeNumber(value) } : row ) diff --git a/web/src/lib/draft-storage.ts b/web/src/lib/draft-storage.ts index bad6364..e85ab4b 100644 --- a/web/src/lib/draft-storage.ts +++ b/web/src/lib/draft-storage.ts @@ -87,14 +87,14 @@ function parseStoredDraft(text: string): AicDraft | null { const record = asRecord(row); return { itemKey: asString(record.itemKey), - value: asInt(record.value) + value: asNonNegativeNumber(record.value) }; }), consumption: consumptionRows.map((row) => { const record = asRecord(row); return { itemKey: asString(record.itemKey), - value: asInt(record.value) + value: asNonNegativeNumber(record.value) }; }), outposts: outpostRows.map((row) => {