diff --git a/crates/end_io/src/aic.rs b/crates/end_io/src/aic.rs index ef20ca0..f1e8825 100644 --- a/crates/end_io/src/aic.rs +++ b/crates/end_io/src/aic.rs @@ -2,7 +2,8 @@ use crate::error::map_aic_build_error; use crate::schema::{AicToml, PowerToml}; use crate::{Error, Result}; use end_model::{ - AicInputs, Catalog, ItemPosF64Map, ItemU32Map, OutpostInput, PosF64, PowerConfig, Stage2Weights, + AicInputs, Catalog, FacilityU32Map, ItemPosF64Map, ItemU32Map, OutpostInput, PosF64, + PowerConfig, Stage2Weights, }; use generativity::{Guard, make_guard}; use std::collections::BTreeMap; @@ -149,6 +150,23 @@ fn resolve_aic<'cid, 'sid>( external_consumption_per_min.insert(item, value); } + let facility_machines_max_span = raw.facility_machines_max.span(); + let raw_facility_machines_max = raw.facility_machines_max.into_inner(); + let mut facility_machines_max = FacilityU32Map::with_capacity(raw_facility_machines_max.len()); + for (facility_key, value) in raw_facility_machines_max { + let facility_key = facility_key.into_inner(); + let value = value.into_inner(); + let facility = catalog + .facility_id(facility_key.as_str()) + .ok_or_else(|| Error::UnknownFacility { + path: path.clone(), + key: facility_key.into(), + span: Some(facility_machines_max_span.clone()), + src: Some(Arc::clone(&src)), + })?; + facility_machines_max.insert(facility, value); + } + let mut builder = AicInputs::builder( guard, power_config, @@ -156,6 +174,7 @@ fn resolve_aic<'cid, 'sid>( external_consumption_per_min, ) .region(region) + .facility_machines_max(facility_machines_max) .stage2_weights(stage2_weights); let mut outpost_spans = BTreeMap::new(); diff --git a/crates/end_io/src/schema.rs b/crates/end_io/src/schema.rs index 3b8cc5e..0a8fce8 100644 --- a/crates/end_io/src/schema.rs +++ b/crates/end_io/src/schema.rs @@ -125,6 +125,8 @@ pub(crate) struct AicToml { pub(crate) supply_per_min: Spanned>, #[serde(default = "default_empty_spanned_item_positive_f64_map")] pub(crate) external_consumption_per_min: Spanned>, + #[serde(default = "default_empty_spanned_key_non_negative_u32_map")] + pub(crate) facility_machines_max: Spanned>, #[serde(default)] pub(crate) outposts: Box<[Spanned]>, } @@ -544,3 +546,7 @@ fn parse_supported_aic_version(value: i64) -> Result { fn default_empty_spanned_item_positive_f64_map() -> Spanned> { Spanned::new(0..0, BTreeMap::new()) } + +fn default_empty_spanned_key_non_negative_u32_map() -> Spanned> { + Spanned::new(0..0, BTreeMap::new()) +} diff --git a/crates/end_model/src/aic_input/mod.rs b/crates/end_model/src/aic_input/mod.rs index f235e12..e479df8 100644 --- a/crates/end_model/src/aic_input/mod.rs +++ b/crates/end_model/src/aic_input/mod.rs @@ -1,8 +1,8 @@ mod model; pub use model::{ - AicBuildError, AicInputs, AicInputsBuilder, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId, - OutpostInput, PowerConfig, Region, Stage2Weights, + AicBuildError, AicInputs, AicInputsBuilder, FacilityU32Map, ItemNonZeroU32Map, ItemPosF64Map, + ItemU32Map, OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights, }; #[cfg(test)] diff --git a/crates/end_model/src/aic_input/model/builder.rs b/crates/end_model/src/aic_input/model/builder.rs index c4093b4..ea5946f 100644 --- a/crates/end_model/src/aic_input/model/builder.rs +++ b/crates/end_model/src/aic_input/model/builder.rs @@ -3,8 +3,8 @@ use std::collections::HashSet; use generativity::Guard; use super::{ - AicBuildError, AicInputs, ItemPosF64Map, OutpostId, OutpostInput, PowerConfig, Region, - Stage2Weights, + AicBuildError, AicInputs, FacilityU32Map, ItemPosF64Map, OutpostId, OutpostInput, PowerConfig, + Region, Stage2Weights, }; #[derive(Debug)] @@ -12,6 +12,7 @@ pub struct AicInputsBuilder<'cid, 'sid> { region: Region, supply_per_min: ItemPosF64Map<'cid>, external_consumption_per_min: ItemPosF64Map<'cid>, + facility_machines_max: FacilityU32Map<'cid>, outposts: Vec>, outpost_keys: HashSet, power_config: PowerConfig, @@ -30,6 +31,7 @@ impl<'cid, 'sid> AicInputs<'cid, 'sid> { region: Region::FourthValley, supply_per_min, external_consumption_per_min, + facility_machines_max: FacilityU32Map::default(), outposts: Vec::new(), outpost_keys: HashSet::new(), power_config, @@ -45,6 +47,11 @@ impl<'cid, 'sid> AicInputsBuilder<'cid, 'sid> { self } + pub fn facility_machines_max(mut self, facility_machines_max: FacilityU32Map<'cid>) -> Self { + self.facility_machines_max = facility_machines_max; + self + } + pub fn stage2_weights(mut self, stage2_weights: Stage2Weights) -> Self { self.stage2_weights = stage2_weights; self @@ -68,6 +75,7 @@ impl<'cid, 'sid> AicInputsBuilder<'cid, 'sid> { region, supply_per_min, external_consumption_per_min, + facility_machines_max, outposts, outpost_keys: _, power_config, @@ -79,6 +87,7 @@ impl<'cid, 'sid> AicInputsBuilder<'cid, 'sid> { region, supply_per_min, external_consumption_per_min, + facility_machines_max, outposts: outposts.into_boxed_slice(), power_config, stage2_weights, diff --git a/crates/end_model/src/aic_input/model/mod.rs b/crates/end_model/src/aic_input/model/mod.rs index bee9072..aadeca8 100644 --- a/crates/end_model/src/aic_input/model/mod.rs +++ b/crates/end_model/src/aic_input/model/mod.rs @@ -3,8 +3,8 @@ mod types; pub use builder::AicInputsBuilder; pub use types::{ - AicBuildError, AicInputs, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId, OutpostInput, PowerConfig, - Region, Stage2Weights, + AicBuildError, AicInputs, FacilityU32Map, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, + OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights, }; impl<'cid, 'sid> AicInputs<'cid, 'sid> { @@ -28,6 +28,10 @@ impl<'cid, 'sid> AicInputs<'cid, 'sid> { &self.external_consumption_per_min } + pub fn facility_machines_max(&self) -> &FacilityU32Map<'cid> { + &self.facility_machines_max + } + pub fn outposts(&self) -> &[OutpostInput<'cid>] { &self.outposts } diff --git a/crates/end_model/src/aic_input/model/types.rs b/crates/end_model/src/aic_input/model/types.rs index 5ed8288..ffaf0f6 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, PosF64}; +use crate::{DisplayName, FacilityId, ItemId, Key, PosF64}; #[derive(Debug, Clone, Copy, PartialEq)] pub struct Stage2Weights { @@ -163,6 +163,76 @@ impl<'id> IntoIterator for ItemU32Map<'id> { } } +#[derive(Debug, Clone, Default, PartialEq)] +pub struct FacilityU32Map<'id>(VecMap, u32>); + +impl<'id> FacilityU32Map<'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, facility: FacilityId<'id>, value: u32) -> Option { + self.0.insert(facility, value) + } + + pub fn get(&self, facility: FacilityId<'id>) -> Option<&u32> { + self.0.get(&facility) + } + + pub fn iter(&self) -> impl Iterator, u32)> + '_ { + self.0.iter().map(|(facility, value)| (*facility, *value)) + } +} + +impl<'id> Extend<(FacilityId<'id>, u32)> for FacilityU32Map<'id> { + fn extend, u32)>>(&mut self, iter: T) { + for (facility, value) in iter { + self.insert(facility, value); + } + } +} + +impl<'id> FromIterator<(FacilityId<'id>, u32)> for FacilityU32Map<'id> { + fn from_iter, u32)>>(iter: T) -> Self { + let mut map = Self::new(); + map.extend(iter); + map + } +} + +impl<'id, const N: usize> From<[(FacilityId<'id>, u32); N]> for FacilityU32Map<'id> { + fn from(value: [(FacilityId<'id>, u32); N]) -> Self { + value.into_iter().collect() + } +} + +impl<'id> From, u32)>> for FacilityU32Map<'id> { + fn from(value: Vec<(FacilityId<'id>, u32)>) -> Self { + value.into_iter().collect() + } +} + +impl<'id> IntoIterator for FacilityU32Map<'id> { + type Item = (FacilityId<'id>, u32); + type IntoIter = vector_map::IntoIter, u32>; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + #[derive(Debug, Clone, Default, PartialEq)] pub struct ItemNonZeroU32Map<'id>(VecMap, NonZeroU32>); @@ -317,6 +387,7 @@ pub struct AicInputs<'cid, 'sid> { pub(super) region: Region, pub(super) supply_per_min: ItemPosF64Map<'cid>, pub(super) external_consumption_per_min: ItemPosF64Map<'cid>, + pub(super) facility_machines_max: FacilityU32Map<'cid>, pub(super) outposts: Box<[OutpostInput<'cid>]>, pub(super) power_config: PowerConfig, pub(super) stage2_weights: Stage2Weights, diff --git a/crates/end_model/src/lib.rs b/crates/end_model/src/lib.rs index 56ea5cd..238a5cd 100644 --- a/crates/end_model/src/lib.rs +++ b/crates/end_model/src/lib.rs @@ -5,8 +5,8 @@ mod optimization; mod text; pub use aic_input::{ - AicBuildError, AicInputs, AicInputsBuilder, ItemNonZeroU32Map, ItemPosF64Map, ItemU32Map, OutpostId, - OutpostInput, PowerConfig, Region, Stage2Weights, + AicBuildError, AicInputs, AicInputsBuilder, FacilityU32Map, ItemNonZeroU32Map, ItemPosF64Map, + ItemU32Map, OutpostId, OutpostInput, PowerConfig, Region, Stage2Weights, }; pub use catalog::{ Catalog, CatalogBuildError, CatalogBuilder, FacilityDef, FacilityId, FacilityRegions, ItemDef, diff --git a/crates/end_opt/src/solver.rs b/crates/end_opt/src/solver.rs index 8c8edfd..907bfe9 100644 --- a/crates/end_opt/src/solver.rs +++ b/crates/end_opt/src/solver.rs @@ -480,6 +480,18 @@ fn solve_stage<'cid, 'sid>( model.with(constraint!(rv.x <= rv.throughput_per_min * rv.y)) }); + model = aic + .facility_machines_max() + .iter() + .fold(model, |model, (facility, max_machines)| { + let facility_machines: Expression = recipe_vars + .iter() + .filter(|rv| rv.facility == facility) + .map(|rv| rv.y) + .sum(); + model.with(constraint!(facility_machines <= max_machines as f64)) + }); + // Apply item balance constraints: // - For fluids: equality (must be exactly 0, no storage allowed) // - For non-fluids: inequality (>= 0, surplus can go to warehouse) diff --git a/crates/end_opt/tests/two_stage_regression.rs b/crates/end_opt/tests/two_stage_regression.rs index 52fc180..8761dec 100644 --- a/crates/end_opt/tests/two_stage_regression.rs +++ b/crates/end_opt/tests/two_stage_regression.rs @@ -1,8 +1,8 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use end_model::{ - AicInputs, Catalog, DisplayName, FacilityDef, FacilityRegions, ItemDef, Key, OutpostInput, - PosF64, PowerConfig, Region, Stack, Stage2Weights, ThermalBankDef, + AicInputs, Catalog, DisplayName, FacilityDef, FacilityRegions, FacilityU32Map, ItemDef, Key, + OutpostInput, PosF64, PowerConfig, Region, Stack, Stage2Weights, ThermalBankDef, }; use end_opt::{Error, NEAR_INT_EPS, run_two_stage}; use generativity::Guard; @@ -270,6 +270,101 @@ fn run_two_stage_allows_empty_recipes_with_direct_external_sales() { ); } +#[test] +fn run_two_stage_respects_facility_machines_max_constraint() { + make_guard!(guard); + let mut b = Catalog::builder(guard); + let ore = b + .add_item(ItemDef { + key: key("Ore"), + en: name("Ore"), + zh: name("Ore_zh"), + is_fluid: false, + }) + .expect("add ore"); + let ingot = b + .add_item(ItemDef { + key: key("Ingot"), + en: name("Ingot"), + zh: name("Ingot_zh"), + is_fluid: false, + }) + .expect("add ingot"); + + let smelter = b + .add_facility(FacilityDef { + key: key("Smelter"), + power_w: nz(10), + en: name("Smelter"), + zh: name("Smelter_zh"), + regions: FacilityRegions::All, + }) + .expect("add machine"); + + let mut b = b + .add_thermal_bank(ThermalBankDef { + key: key("Thermal Bank"), + en: name("Thermal Bank"), + zh: name("Thermal_Bank_zh"), + }) + .expect("add thermal bank"); + + b.push_recipe( + smelter, + nz(60), + vec![Stack { + item: ore, + count: nz(1), + }] + .into(), + vec![Stack { + item: ingot, + count: nz(1), + }] + .into(), + ) + .expect("push recipe"); + + let catalog = b.build(); + + make_guard!(aic_guard); + let facility_machines_max: FacilityU32Map<'_> = vec![(smelter, 1)].into(); + let mut aic_builder = AicInputs::builder( + aic_guard, + PowerConfig::default(), + vec![(ore, PosF64::new(10.0).expect("positive"))].into(), + Default::default(), + ) + .facility_machines_max(facility_machines_max); + aic_builder + .add_outpost(OutpostInput { + key: key("Camp"), + en: Some(name("Camp")), + zh: Some(name("Camp_zh")), + money_cap_per_hour: 600, + prices: vec![(ingot, 5)].into(), + }) + .expect("valid aic outpost"); + let aic = aic_builder.build(); + + make_guard!(result_guard); + let result = run_two_stage(&catalog, &aic, result_guard).expect("solve sample model"); + + assert_eq!( + result.stage1.total_machines, 1, + "stage1 should respect facility machine cap" + ); + assert_eq!( + result.stage2.total_machines, 1, + "stage2 should respect facility machine cap" + ); + assert!( + (result.stage1.revenue_per_min - 5.0).abs() <= 1e-9, + "stage1 revenue should be capped at 1 ingot/min by facility cap, got {}", + result.stage1.revenue_per_min + ); +} + #[test] fn max_money_slack_does_not_virtualize_fluid_surplus_into_stockpile() { make_guard!(guard); diff --git a/crates/end_web/src/api.rs b/crates/end_web/src/api.rs index 49e790c..d1aa83c 100644 --- a/crates/end_web/src/api.rs +++ b/crates/end_web/src/api.rs @@ -9,9 +9,9 @@ use end_opt::run_two_stage; use generativity::make_guard; use crate::dto::{ - BootstrapPayload, CatalogDto, CatalogItemDto, ExternalSupplySlackDto, FacilityUsageDto, - LogisticsEdgeDto, LogisticsGraphDto, LogisticsItemSummaryDto, LogisticsNodeDto, - OutpostValueDto, PowerSummaryDto, SaleValueDto, SolvePayload, SummaryDto, + BootstrapPayload, CatalogDto, CatalogFacilityDto, CatalogItemDto, ExternalSupplySlackDto, + FacilityUsageDto, LogisticsEdgeDto, LogisticsGraphDto, LogisticsItemSummaryDto, + LogisticsNodeDto, OutpostValueDto, PowerSummaryDto, SaleValueDto, SolvePayload, SummaryDto, }; use crate::{Error, Lang, Result}; @@ -30,6 +30,17 @@ pub fn bootstrap(lang: Lang) -> Result { .collect::>(); items.sort_by(|lhs, rhs| lhs.key.cmp(&rhs.key)); + let mut facilities = catalog + .facilities() + .iter() + .map(|facility| CatalogFacilityDto { + key: facility.key.as_str().into(), + en: facility.en.as_str().into(), + zh: facility.zh.as_str().into(), + }) + .collect::>(); + facilities.sort_by(|lhs, rhs| lhs.key.cmp(&rhs.key)); + // `lang` is currently not used by bootstrap payload fields, but keeping it in signature // makes the frontend contract symmetric with solve API and future localization extensions. let _ = lang; @@ -37,6 +48,7 @@ pub fn bootstrap(lang: Lang) -> Result { Ok(BootstrapPayload { catalog: CatalogDto { items: items.into_boxed_slice(), + facilities: facilities.into_boxed_slice(), }, }) } diff --git a/crates/end_web/src/dto.rs b/crates/end_web/src/dto.rs index 82af48e..abe232e 100644 --- a/crates/end_web/src/dto.rs +++ b/crates/end_web/src/dto.rs @@ -13,6 +13,7 @@ pub struct BootstrapPayload { #[serde(rename_all = "camelCase")] pub struct CatalogDto { pub items: Box<[CatalogItemDto]>, + pub facilities: Box<[CatalogFacilityDto]>, } #[derive(Debug, Serialize)] @@ -23,6 +24,14 @@ pub struct CatalogItemDto { pub zh: Box, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogFacilityDto { + pub key: Box, + pub en: Box, + pub zh: Box, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SolvePayload { diff --git a/crates/end_web/src/lib.rs b/crates/end_web/src/lib.rs index c23c3fc..56ce0b8 100644 --- a/crates/end_web/src/lib.rs +++ b/crates/end_web/src/lib.rs @@ -6,9 +6,9 @@ mod lang; pub use api::{bootstrap, solve_from_aic_toml}; pub use dto::{ - BootstrapPayload, CatalogDto, CatalogItemDto, ExternalSupplySlackDto, FacilityUsageDto, - LogisticsEdgeDto, LogisticsGraphDto, LogisticsItemSummaryDto, LogisticsNodeDto, - OutpostValueDto, PowerSummaryDto, SaleValueDto, SolvePayload, SummaryDto, + BootstrapPayload, CatalogDto, CatalogFacilityDto, CatalogItemDto, ExternalSupplySlackDto, + FacilityUsageDto, LogisticsEdgeDto, LogisticsGraphDto, LogisticsItemSummaryDto, + LogisticsNodeDto, OutpostValueDto, PowerSummaryDto, SaleValueDto, SolvePayload, SummaryDto, }; pub use error::{Error, Result}; pub use ffi::{end_web_bootstrap, end_web_free_slice, end_web_solve_from_aic_toml}; @@ -30,6 +30,10 @@ mod tests { !payload.catalog.items.is_empty(), "catalog items should not be empty" ); + assert!( + !payload.catalog.facilities.is_empty(), + "catalog facilities should not be empty" + ); } #[test] diff --git a/web/src/components/layout/HomeDesktopLayout.svelte b/web/src/components/layout/HomeDesktopLayout.svelte index 4e18b14..5d8c23a 100644 --- a/web/src/components/layout/HomeDesktopLayout.svelte +++ b/web/src/components/layout/HomeDesktopLayout.svelte @@ -11,7 +11,7 @@ import { createHydrationPersistGate } from "../../lib/hydration-persist-gate.svelte"; import { translateByLang } from "../../lib/lang"; import type { SolveState } from "../../lib/solve-state"; - import type { AicDraft, CatalogItemDto, LangTag } from "../../lib/types"; + import type { AicDraft, CatalogFacilityDto, CatalogItemDto, LangTag } from "../../lib/types"; import type { OutpostSelection } from "../../lib/outpost-selection"; import { localStorageGet } from "../../lib/storage"; import { @@ -28,6 +28,7 @@ lang: LangTag; draft: AicDraft; catalogItems: CatalogItemDto[]; + catalogFacilities: CatalogFacilityDto[]; selectedOutpostIndex: OutpostSelection; isBootstrapping: boolean; solveState: SolveState; @@ -45,6 +46,7 @@ lang, draft, catalogItems, + catalogFacilities, selectedOutpostIndex, isBootstrapping, solveState, @@ -121,6 +123,7 @@ {lang} {draft} {catalogItems} + {catalogFacilities} {selectedOutpostIndex} isResetDisabled={isBootstrapping} actions={editorActions} diff --git a/web/src/components/layout/HomeMobileLayout.svelte b/web/src/components/layout/HomeMobileLayout.svelte index e864a02..90330a0 100644 --- a/web/src/components/layout/HomeMobileLayout.svelte +++ b/web/src/components/layout/HomeMobileLayout.svelte @@ -7,7 +7,7 @@ import type { FlowSnapshot } from "../../lib/export/flow-snapshot"; import { translateByLang } from "../../lib/lang"; import type { SolveState } from "../../lib/solve-state"; - import type { AicDraft, CatalogItemDto, LangTag } from "../../lib/types"; + import type { AicDraft, CatalogFacilityDto, CatalogItemDto, LangTag } from "../../lib/types"; import type { OutpostSelection } from "../../lib/outpost-selection"; type MobileTab = "editor" | "result" | "graph"; @@ -16,6 +16,7 @@ lang: LangTag; draft: AicDraft; catalogItems: CatalogItemDto[]; + catalogFacilities: CatalogFacilityDto[]; selectedOutpostIndex: OutpostSelection; isBootstrapping: boolean; solveState: SolveState; @@ -28,6 +29,7 @@ lang, draft, catalogItems, + catalogFacilities, selectedOutpostIndex, isBootstrapping, solveState, @@ -82,6 +84,7 @@ {lang} {draft} {catalogItems} + {catalogFacilities} {selectedOutpostIndex} isResetDisabled={isBootstrapping} actions={editorActions} diff --git a/web/src/components/workbench/EditorPanel.svelte b/web/src/components/workbench/EditorPanel.svelte index f1591b5..b5f8ec0 100644 --- a/web/src/components/workbench/EditorPanel.svelte +++ b/web/src/components/workbench/EditorPanel.svelte @@ -23,6 +23,7 @@ lang, draft, catalogItems, + catalogFacilities, selectedOutpostIndex, isResetDisabled, actions, @@ -52,6 +53,12 @@ label: t(item.zh, item.en), })), ); + const facilityOptions = $derived( + catalogFacilities.map((facility) => ({ + value: facility.key, + label: t(facility.zh, facility.en), + })), + ); const regionOptions = $derived([ { value: "fourth_valley", label: t("四号谷地", "Fourth Valley") }, { value: "wuling", label: t("武陵", "Wuling") }, @@ -419,6 +426,66 @@ {/each} +
+ + {#snippet title()} +
+

{t("设备数量", "Facility Machines Max")}

+ +
+ {/snippet} + + {#snippet controls()} + + {/snippet} +
+ + {#if draft.facilityMachinesMax.length === 0} +

{t("暂无设备限制条目。", "No facility limit rows yet.")}

+ {/if} + + {#each draft.facilityMachinesMax as row, rowIndex (rowIndex)} +
+ + actions.facilityMachinesMax.setKey(rowIndex, nextValue)} + /> + + + actions.facilityMachinesMax.setValue( + rowIndex, + Number((event.currentTarget as HTMLInputElement).value), + )} + /> + + actions.facilityMachinesMax.remove(rowIndex)} + ariaLabel={t("删除设备限制条目", "Remove facility limit row")} + /> +
+ {/each} +
+
{#snippet title()} diff --git a/web/src/lib/aic.test.ts b/web/src/lib/aic.test.ts index f96d6f8..2a1e817 100644 --- a/web/src/lib/aic.test.ts +++ b/web/src/lib/aic.test.ts @@ -24,6 +24,10 @@ describe('aic toml conversions', () => { { itemKey: 'IronOre', value: 15 }, { itemKey: 'Water', value: 8 } ], + facilityMachinesMax: [ + { facilityKey: 'Smelter', value: 0 }, + { facilityKey: 'Assembler', value: 12 } + ], outposts: [ { key: 'Refugee_Camp', @@ -48,6 +52,7 @@ describe('aic toml conversions', () => { expect(parsed.objective.maxMoneySlack).toBe(0.5); expect(parsed.supply).toHaveLength(2); expect(parsed.consumption).toHaveLength(2); + expect(parsed.facilityMachinesMax).toHaveLength(2); expect(parsed.outposts).toHaveLength(1); expect(parsed.outposts[0]?.key).toBe('Refugee_Camp'); expect(parsed.outposts[0]?.name).toBe('Refugee Camp'); @@ -72,6 +77,7 @@ describe('aic toml conversions', () => { { itemKey: 'CopperOre', value: 0.25 } ], consumption: [{ itemKey: 'Water', value: 2.75 }], + facilityMachinesMax: [], outposts: [] }; @@ -147,6 +153,7 @@ money_cap_per_hour = 100 }, supply: [], consumption: [], + facilityMachinesMax: [], outposts: [] }; diff --git a/web/src/lib/aic.ts b/web/src/lib/aic.ts index 282f3ab..9378465 100644 --- a/web/src/lib/aic.ts +++ b/web/src/lib/aic.ts @@ -28,6 +28,18 @@ function parseItemFlowRows(map: Record): { itemKey: string; val .sort((a, b) => a.itemKey.localeCompare(b.itemKey)); } +function parseFacilityMachineMaxRows( + map: Record +): { facilityKey: string; value: number }[] { + return Object.entries(map) + .map(([facilityKey, value]) => ({ + facilityKey, + value: asInt(value) + })) + .filter((row) => row.facilityKey.trim().length > 0) + .sort((a, b) => a.facilityKey.localeCompare(b.facilityKey)); +} + function parsePrices(map: Record): PriceRow[] { return Object.entries(map) .map(([itemKey, value]) => ({ @@ -143,6 +155,12 @@ function cleanDraft(draft: AicDraft): AicDraft { itemKey: row.itemKey.trim(), value: asNonNegativeNumber(row.value, 0), })), + facilityMachinesMax: draft.facilityMachinesMax + .filter((row) => row.facilityKey.trim().length > 0) + .map((row) => ({ + facilityKey: row.facilityKey.trim(), + value: asInt(row.value), + })), outposts: draft.outposts .filter((outpost) => outpost.key.trim().length > 0) .map((outpost) => ({ @@ -164,6 +182,7 @@ export function parseAicToml(tomlText: string): AicDraft { objective: parseObjective(parsed.objective, parsed.stage2), supply: parseItemFlowRows(asRecord(parsed.supply_per_min)), consumption: parseItemFlowRows(asRecord(parsed.external_consumption_per_min)), + facilityMachinesMax: parseFacilityMachineMaxRows(asRecord(parsed.facility_machines_max)), outposts: Array.isArray(parsed.outposts) ? parsed.outposts.map(parseOutpost) : [] }); } @@ -182,6 +201,10 @@ export function buildAicToml(draft: AicDraft): string { .map((row) => [row.itemKey, asNonNegativeNumber(row.value, 0)]) ); + const facilityMachinesMax = Object.fromEntries( + cleaned.facilityMachinesMax.map((row) => [row.facilityKey, asInt(row.value)]) + ); + const outposts = cleaned.outposts.map((outpost) => { const prices = Object.fromEntries(outpost.prices.map((row) => [row.itemKey, asInt(row.price)])); @@ -223,6 +246,7 @@ export function buildAicToml(draft: AicDraft): string { power, supply_per_min: supplyPerMin, external_consumption_per_min: externalConsumptionPerMin, + facility_machines_max: facilityMachinesMax, outposts }; @@ -230,5 +254,9 @@ export function buildAicToml(draft: AicDraft): string { root.objective = objective; } + if (Object.keys(facilityMachinesMax).length === 0) { + delete root.facility_machines_max; + } + return stringifyToml(root); } diff --git a/web/src/lib/draft-actions.test.ts b/web/src/lib/draft-actions.test.ts index 5079904..54cc023 100644 --- a/web/src/lib/draft-actions.test.ts +++ b/web/src/lib/draft-actions.test.ts @@ -1,15 +1,18 @@ import { describe, expect, it } from 'vitest'; import { addConsumptionRow, + addFacilityMachinesMaxRow, addOutpost, addPriceRow, addSupplyRow, normalizeSelectedOutpostIndex, removeConsumptionRow, + removeFacilityMachinesMaxRow, removeOutpost, removePriceRow, removeSupplyRow, setConsumptionValue, + setFacilityMachinesMaxValue, setObjectiveWeight, setOutpostField, setPowerEnabled, @@ -34,6 +37,7 @@ const EMPTY_DRAFT: AicDraft = { }, supply: [], consumption: [], + facilityMachinesMax: [], outposts: [] }; @@ -99,6 +103,18 @@ describe('draft actions', () => { expect(after.consumption[0]?.itemKey).toBe('B'); }); + it('adds and removes facility machine max rows by index', () => { + let draft = addFacilityMachinesMaxRow(EMPTY_DRAFT, 'Smelter'); + draft = setFacilityMachinesMaxValue(draft, 0, 3.7); + expect(draft.facilityMachinesMax[0]?.facilityKey).toBe('Smelter'); + expect(draft.facilityMachinesMax[0]?.value).toBe(4); + + draft = addFacilityMachinesMaxRow(draft, 'Assembler'); + const after = removeFacilityMachinesMaxRow(draft, 0); + expect(after.facilityMachinesMax).toHaveLength(1); + expect(after.facilityMachinesMax[0]?.facilityKey).toBe('Assembler'); + }); + it('normalizes selected outpost index for empty and invalid states', () => { expect(normalizeSelectedOutpostIndex(EMPTY_DRAFT, { kind: 'selected', index: 2 })).toEqual({ kind: 'none' }); diff --git a/web/src/lib/draft-actions.ts b/web/src/lib/draft-actions.ts index 2c7f1df..d8c7610 100644 --- a/web/src/lib/draft-actions.ts +++ b/web/src/lib/draft-actions.ts @@ -135,6 +135,34 @@ export function setConsumptionValue(draft: AicDraft, index: number, value: numbe }; } +export function setFacilityMachinesMaxKey(draft: AicDraft, index: number, value: string): AicDraft { + return { + ...draft, + facilityMachinesMax: draft.facilityMachinesMax.map((row, rowIndex) => + rowIndex === index + ? { + ...row, + facilityKey: value + } + : row + ) + }; +} + +export function setFacilityMachinesMaxValue(draft: AicDraft, index: number, value: number): AicDraft { + return { + ...draft, + facilityMachinesMax: draft.facilityMachinesMax.map((row, rowIndex) => + rowIndex === index + ? { + ...row, + value: asNonNegativeInt(value) + } + : row + ) + }; +} + export function addSupplyRow(draft: AicDraft, firstItemKey: string): AicDraft { return { ...draft, @@ -149,6 +177,13 @@ export function addConsumptionRow(draft: AicDraft, firstItemKey: string): AicDra }; } +export function addFacilityMachinesMaxRow(draft: AicDraft, firstFacilityKey: string): AicDraft { + return { + ...draft, + facilityMachinesMax: [...draft.facilityMachinesMax, { facilityKey: firstFacilityKey, value: 1 }] + }; +} + export function removeSupplyRow(draft: AicDraft, index: number): AicDraft { return { ...draft, @@ -163,6 +198,13 @@ export function removeConsumptionRow(draft: AicDraft, index: number): AicDraft { }; } +export function removeFacilityMachinesMaxRow(draft: AicDraft, index: number): AicDraft { + return { + ...draft, + facilityMachinesMax: draft.facilityMachinesMax.filter((_, rowIndex) => rowIndex !== index) + }; +} + export function createOutpost(key: string): Outpost { return { key, diff --git a/web/src/lib/draft-storage.ts b/web/src/lib/draft-storage.ts index e85ab4b..2348645 100644 --- a/web/src/lib/draft-storage.ts +++ b/web/src/lib/draft-storage.ts @@ -22,6 +22,7 @@ function parseStoredDraft(text: string): AicDraft | null { const region = regionRaw === 'fourth_valley' ? 'fourth_valley' : 'wuling'; const supplyRows = Array.isArray(parsed.supply) ? parsed.supply : []; const consumptionRows = Array.isArray(parsed.consumption) ? parsed.consumption : []; + const facilityRows = Array.isArray(parsed.facilityMachinesMax) ? parsed.facilityMachinesMax : []; const outpostRows = Array.isArray(parsed.outposts) ? parsed.outposts : []; const asNonNegativeNumber = (value: unknown): number => { const parsedNumber = typeof value === 'number' ? value : Number(value); @@ -97,6 +98,13 @@ function parseStoredDraft(text: string): AicDraft | null { value: asNonNegativeNumber(record.value) }; }), + facilityMachinesMax: facilityRows.map((row) => { + const record = asRecord(row); + return { + facilityKey: asString(record.facilityKey), + value: asInt(record.value) + }; + }), outposts: outpostRows.map((row) => { const record = asRecord(row); const priceRows = Array.isArray(record.prices) ? record.prices : []; diff --git a/web/src/lib/editor-actions.ts b/web/src/lib/editor-actions.ts index f867a76..37a6405 100644 --- a/web/src/lib/editor-actions.ts +++ b/web/src/lib/editor-actions.ts @@ -1,4 +1,4 @@ -import type { AicDraft, CatalogItemDto } from './types'; +import type { AicDraft, CatalogFacilityDto, CatalogItemDto } from './types'; import type { OutpostSelection } from './outpost-selection'; export type OutpostField = 'name' | 'moneyCapPerHour'; @@ -25,6 +25,12 @@ export interface EditorActions { setKey: (index: number, key: string) => void; setValue: (index: number, value: number) => void; }; + facilityMachinesMax: { + add: () => void; + remove: (index: number) => void; + setKey: (index: number, key: string) => void; + setValue: (index: number, value: number) => void; + }; outposts: { add: () => void; remove: (index: number) => void; @@ -43,6 +49,7 @@ export interface EditorPanelProps { lang: 'zh' | 'en'; draft: AicDraft; catalogItems: CatalogItemDto[]; + catalogFacilities: CatalogFacilityDto[]; selectedOutpostIndex: OutpostSelection; isResetDisabled: boolean; actions: EditorActions; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 2f2835b..14074ee 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -19,9 +19,16 @@ export interface CatalogItemDto { zh: string; } +export interface CatalogFacilityDto { + key: string; + en: string; + zh: string; +} + export interface BootstrapPayload { catalog: { items: CatalogItemDto[]; + facilities: CatalogFacilityDto[]; }; } @@ -149,6 +156,11 @@ export interface ConsumptionRow { value: number; } +export interface FacilityMachinesMaxRow { + facilityKey: string; + value: number; +} + export interface PriceRow { itemKey: string; price: number; @@ -167,6 +179,7 @@ export interface AicDraft { objective: Objective; supply: SupplyRow[]; consumption: ConsumptionRow[]; + facilityMachinesMax: FacilityMachinesMaxRow[]; outposts: Outpost[]; } @@ -184,5 +197,6 @@ export const EMPTY_DRAFT: AicDraft = { }, supply: [], consumption: [], + facilityMachinesMax: [], outposts: [] }; diff --git a/web/src/routes/Home.svelte b/web/src/routes/Home.svelte index dc9ef0f..4e39490 100644 --- a/web/src/routes/Home.svelte +++ b/web/src/routes/Home.svelte @@ -17,16 +17,20 @@ } from "../lib/outpost-selection"; import { addConsumptionRow, + addFacilityMachinesMaxRow, addOutpost, addPriceRow, addSupplyRow, normalizeSelectedOutpostIndex, removeConsumptionRow, + removeFacilityMachinesMaxRow, removeOutpost, removePriceRow, removeSupplyRow, setConsumptionKey, setConsumptionValue, + setFacilityMachinesMaxKey, + setFacilityMachinesMaxValue, setObjectiveWeight, setPowerEnabled, setPowerExternalConsumption, @@ -50,7 +54,7 @@ } from "../lib/solver-controller"; import { renderedOkState, type SolveState } from "../lib/solve-state"; import { translateByLang } from "../lib/lang"; - import type { AicDraft, CatalogItemDto, LangTag } from "../lib/types"; + import type { AicDraft, CatalogFacilityDto, CatalogItemDto, LangTag } from "../lib/types"; import { EMPTY_DRAFT } from "../lib/types"; import { createHydrationPersistGate as createHydrationGate } from "../lib/hydration-persist-gate.svelte"; import { createBackend, type Backend } from "../lib/backend"; @@ -79,6 +83,7 @@ let { lang }: Props = $props(); let catalogItems = $state([]); + let catalogFacilities = $state([]); let draft = $state(structuredClone(EMPTY_DRAFT)); const defaultToml = bundledDefaultAicToml; @@ -214,6 +219,7 @@ const nextBackend = backend ?? await backendReady; const payload = await nextBackend.loadBootstrap(lang); catalogItems = payload.catalog.items; + catalogFacilities = payload.catalog.facilities; } catch (error) { showErrorToast(error instanceof Error ? error.message : String(error)); } finally { @@ -316,6 +322,20 @@ draft = setConsumptionValue(draft, index, value); }, }, + facilityMachinesMax: { + add: () => { + draft = addFacilityMachinesMaxRow(draft, catalogFacilities[0]?.key ?? ""); + }, + remove: (index) => { + draft = removeFacilityMachinesMaxRow(draft, index); + }, + setKey: (index, key) => { + draft = setFacilityMachinesMaxKey(draft, index, key); + }, + setValue: (index, value) => { + draft = setFacilityMachinesMaxValue(draft, index, value); + }, + }, outposts: { add: () => { const next = addOutpost(draft); @@ -421,6 +441,7 @@ {lang} {draft} {catalogItems} + {catalogFacilities} {selectedOutpostIndex} {isBootstrapping} {solveState} @@ -433,6 +454,7 @@ {lang} {draft} {catalogItems} + {catalogFacilities} {selectedOutpostIndex} {isBootstrapping} {solveState}