From e1d7ddbc69abbdd210119f87bda38546cb82d5d6 Mon Sep 17 00:00:00 2001 From: dcsturman Date: Tue, 2 Sep 2025 21:38:27 -0700 Subject: [PATCH 1/3] Core ability to keep goods in manifest --- src/components/trade_computer.rs | 339 +++++++++++++++++++++++++------ src/trade/available_goods.rs | 144 +++++++------ src/trade/ship_manifest.rs | 267 +++++++++++++++++++++++- 3 files changed, 623 insertions(+), 127 deletions(-) diff --git a/src/components/trade_computer.rs b/src/components/trade_computer.rs index 52bf6f1..fe027fb 100644 --- a/src/components/trade_computer.rs +++ b/src/components/trade_computer.rs @@ -95,7 +95,7 @@ //! ### Passenger Revenue //! Calculates passenger income using standard Traveller rates: //! - **High Passage**: Premium passenger service -//! - **Medium Passage**: Standard passenger service +//! - **Medium Passage**: Standard passenger service //! - **Basic Passage**: Economy passenger service //! - **Low Passage**: Cryogenic passenger transport //! @@ -186,7 +186,12 @@ use log::debug; use crate::components::traveller_map::WorldSearch; use crate::systems::world::World; -use crate::trade::available_goods::AvailableGoodsTable; +use crate::trade::available_goods::{AvailableGood, AvailableGoodsTable}; + +#[derive(Clone, Copy)] +struct BuyerBrokerSkillSignal(pub RwSignal); +#[derive(Clone, Copy)] +struct SellerBrokerSkillSignal(pub RwSignal); use crate::trade::available_passengers::AvailablePassengers; use crate::trade::ship_manifest::ShipManifest; use crate::trade::table::TradeTable; @@ -274,6 +279,8 @@ pub fn Trade() -> impl IntoView { // Skills involved, both player and adversary. let buyer_broker_skill = RwSignal::new(0); let seller_broker_skill = RwSignal::new(0); + provide_context(BuyerBrokerSkillSignal(buyer_broker_skill)); + provide_context(SellerBrokerSkillSignal(seller_broker_skill)); let steward_skill = RwSignal::new(0); let origin_world_name = RwSignal::new(origin_world.read_untracked().name.clone()); @@ -298,7 +305,7 @@ pub fn Trade() -> impl IntoView { return; }; world.gen_trade_classes(); - world.coordinates = dest_coords.get(); + world.coordinates = origin_coords.get(); world.travel_zone = origin_zone.get(); origin_world.set(world); @@ -310,7 +317,7 @@ pub fn Trade() -> impl IntoView { false, ) .unwrap(); - ship_manifest.set(ShipManifest::default()); + available_goods.set(ag); } }); @@ -337,7 +344,7 @@ pub fn Trade() -> impl IntoView { Effect::new(move |_| { console_log("Updating goods pricing"); - ship_manifest.set(ShipManifest::default()); + // Do not wipe the manifest; keep trade goods and sell plans across recalculations let mut ag = available_goods.write(); ag.price_goods_to_buy( &origin_world.read().get_trade_classes(), @@ -350,6 +357,16 @@ pub fn Trade() -> impl IntoView { seller_broker_skill.get(), ); ag.sort_by_discount(); + + // Also price goods currently in the manifest, even if not available in this market + let dest_classes_opt = dest_world.get().as_ref().map(|w| w.get_trade_classes()); + let buyer = buyer_broker_skill.get(); + let supplier = seller_broker_skill.get(); + let mut manifest = ship_manifest.write(); + let mut rng = rand::rng(); + for g in &mut manifest.trade_goods { + g.price_to_sell_rng(dest_classes_opt.as_deref(), buyer, supplier, &mut rng); + } }); // Effect to reset show_sell_price when either origin or destination world changes. @@ -357,6 +374,9 @@ pub fn Trade() -> impl IntoView { let _ = origin_world.get(); let _ = dest_world.get(); show_sell_price.set(ShowSellPriceType(false)); + // Preserve trade goods while resetting only passengers and freight + let mut manifest = ship_manifest.write(); + manifest.reset_passengers_and_freight(); }); // Effect to reset zones when world names change (but not when zones change) @@ -589,6 +609,7 @@ pub fn TradeView() -> impl IntoView { let available_goods = expect_context::>(); let available_passengers = expect_context::>>(); + let ship_manifest = expect_context::>(); let show_sell_price = expect_context::>(); @@ -629,13 +650,20 @@ pub fn TradeView() -> impl IntoView { "Sell Price" "Discount" + "Sell Qty" + {format!("{} ({}t):", good.name, ship_manifest.read().get_trade_good_quantity_by_index(good.source_entry.index))} + + + {move || { + let sell_amt = ship_manifest.read().get_sell_amount_by_index(good.source_entry.index); + if show_sell && good.sell_price.is_some() { + let proceeds = sell_amt as i64 * good.sell_price.unwrap() as i64; + let profit = proceeds - (sell_amt as i64 * good.buy_cost as i64); + format!("sell {}t → {:.2} MCr ({:+.2})", + sell_amt, + proceeds as f64 / 1_000_000.0, + profit as f64 / 1_000_000.0) + } else { + let remaining = ship_manifest.read().get_trade_good_quantity_by_index(good.source_entry.index); + format!("holding {}t @ {:.2} MCr", + remaining, + (remaining as i64 * good.buy_cost as i64) as f64 / 1_000_000.0) + } + }} + + + + } + }).collect::>().into_any() + } + }} + + +
"Revenue"
@@ -1232,19 +1474,9 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView { "Goods Profit:" {move || { - let goods = available_goods.get(); - let cost: i64 = goods.goods.iter() - .map(|good| good.purchased as i64 * good.buy_cost as i64) - .sum(); - let proceeds: i64 = goods.goods.iter() - .map(|good| { - if let Some(sell_price) = good.sell_price { - good.purchased as i64 * sell_price as i64 - } else { - 0 - } - }) - .sum(); + let manifest = ship_manifest.get(); + let cost = manifest.trade_goods_cost(); + let proceeds = manifest.trade_goods_proceeds(); let profit = proceeds - cost; format!("{:.2} MCr", profit as f64 / 1_000_000.0) }} @@ -1256,25 +1488,14 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView { {move || { let manifest = ship_manifest.get(); - let goods = available_goods.get(); let show_sell = show_sell_price.get().0; let passenger_revenue = manifest.passenger_revenue(distance.get()) as i64; let freight_revenue = manifest.freight_revenue(distance.get()) as i64; let goods_profit = if show_sell { - let cost: i64 = goods.goods.iter() - .map(|good| good.purchased as i64 * good.buy_cost as i64) - .sum(); - let proceeds: i64 = goods.goods.iter() - .map(|good| { - if let Some(sell_price) = good.sell_price { - good.purchased as i64 * sell_price as i64 - } else { - 0 - } - }) - .sum(); + let cost = manifest.trade_goods_cost(); + let proceeds = manifest.trade_goods_proceeds(); proceeds - cost } else { 0 diff --git a/src/trade/available_goods.rs b/src/trade/available_goods.rs index 9e59569..30daec7 100644 --- a/src/trade/available_goods.rs +++ b/src/trade/available_goods.rs @@ -595,68 +595,12 @@ impl AvailableGoodsTable { mut rng: impl Rng, ) { for good in &mut self.goods { - if let Some(destination_trade_classes) = &possible_destination_trade_classes { - // Roll 2d6 - let roll = - rng.random_range(1..=6) + rng.random_range(1..=6) + rng.random_range(1..=6); - - let purchase_origin_dm = - find_total_dm(&good.source_entry.purchase_dm, destination_trade_classes); - let sale_origin_dm = - find_total_dm(&good.source_entry.sale_dm, destination_trade_classes); - - // Calculate the modified roll - let modified_roll = roll as i16 - buyer_broker_skill + supplier_broker_skill - - purchase_origin_dm - + sale_origin_dm; - - // Determine the price multiplier based on the modified roll - let price_multiplier = match modified_roll { - i16::MIN..=-3 => 0.1, - -2 => 0.2, - -1 => 0.3, - 0 => 0.4, // 175% - 1 => 0.45, - 2 => 0.5, - 3 => 0.55, - 4 => 0.60, - 5 => 0.65, - 6 => 0.70, - 7 => 0.75, - 8 => 0.80, - 9 => 0.85, - 10 => 0.9, - 11 => 1.0, - 12 => 1.05, - 13 => 1.10, - 14 => 1.15, - 15 => 1.20, - 16 => 1.25, - 17 => 1.30, - 18 => 1.40, - 19 => 1.50, - 20 => 1.60, - 21 => 1.75, - 22 => 2.0, - 23 => 2.5, - 24 => 3.0, - 25.. => 4.0, - }; - - good.sell_price_comment = format!( - "(roll) {} + (broker) {} + (trade mod) {} = {} which gives a multiplier of {}", - roll, - supplier_broker_skill - buyer_broker_skill, - sale_origin_dm - purchase_origin_dm, - modified_roll, - price_multiplier - ); - - // Apply the multiplier to the cost - good.sell_price = Some((good.base_cost as f64 * price_multiplier).round() as i32); - } else { - good.sell_price = None; - } + good.price_to_sell_rng( + possible_destination_trade_classes.as_deref(), + buyer_broker_skill, + supplier_broker_skill, + &mut rng, + ); } } @@ -675,6 +619,80 @@ impl AvailableGoodsTable { } } +impl AvailableGood { + /// Price this good for selling at a destination + /// - If destination trade classes are provided, computes a sell_price and comment + /// - If None, clears sell_price + pub fn price_to_sell_rng( + &mut self, + possible_destination_trade_classes: Option<&[crate::trade::TradeClass]>, + buyer_broker_skill: i16, + supplier_broker_skill: i16, + mut rng: impl rand::Rng, + ) { + if let Some(destination_trade_classes) = possible_destination_trade_classes { + // Roll 3d6 + let roll = rng.random_range(1..=6) + rng.random_range(1..=6) + rng.random_range(1..=6); + + let purchase_origin_dm = find_total_dm(&self.source_entry.purchase_dm, destination_trade_classes); + let sale_origin_dm = find_total_dm(&self.source_entry.sale_dm, destination_trade_classes); + + // Calculate the modified roll (mirror price_goods_to_sell) + let modified_roll = roll as i16 - buyer_broker_skill + supplier_broker_skill + - purchase_origin_dm + + sale_origin_dm; + + // Determine the price multiplier based on the modified roll + let price_multiplier = match modified_roll { + i16::MIN..=-3 => 0.1, + -2 => 0.2, + -1 => 0.3, + 0 => 0.4, + 1 => 0.45, + 2 => 0.5, + 3 => 0.55, + 4 => 0.60, + 5 => 0.65, + 6 => 0.70, + 7 => 0.75, + 8 => 0.80, + 9 => 0.85, + 10 => 0.9, + 11 => 1.0, + 12 => 1.05, + 13 => 1.10, + 14 => 1.15, + 15 => 1.20, + 16 => 1.25, + 17 => 1.30, + 18 => 1.40, + 19 => 1.50, + 20 => 1.60, + 21 => 1.75, + 22 => 2.0, + 23 => 2.5, + 24 => 3.0, + 25.. => 4.0, + }; + + self.sell_price_comment = format!( + "(roll) {} + (broker) {} + (trade mod) {} = {} which gives a multiplier of {}", + roll, + supplier_broker_skill - buyer_broker_skill, + sale_origin_dm - purchase_origin_dm, + modified_roll, + price_multiplier + ); + + self.sell_price = Some((self.base_cost as f64 * price_multiplier).round() as i32); + } else { + self.sell_price = None; + self.sell_price_comment.clear(); + } + } +} + + /// Calculate total trade DMs for a set of world trade classes /// /// Sums all applicable Difficulty Modifiers (DMs) from a trade good's DM map @@ -703,7 +721,7 @@ impl AvailableGoodsTable { /// let total_dm = find_total_dm(&electronics_purchase_dm, &[Agricultural, Rich]); /// // Returns: 3 (2 + 1) /// -/// // Industrial world with no applicable DMs for agricultural products +/// // Industrial world with no applicable DMs for agricultural products /// let total_dm = find_total_dm(&ag_products_purchase_dm, &[Industrial]); /// // Returns: 0 /// ``` diff --git a/src/trade/ship_manifest.rs b/src/trade/ship_manifest.rs index 520fefd..3de6e94 100644 --- a/src/trade/ship_manifest.rs +++ b/src/trade/ship_manifest.rs @@ -3,14 +3,18 @@ //! This module defines the ship manifest structure and revenue calculation //! functionality for passenger and freight transport in the Traveller universe. //! -//! The manifest tracks different classes of passengers and freight lots, +//! The manifest tracks different classes of passengers, freight lots, and trade goods, //! and calculates revenue based on distance traveled and passenger/freight types. -/// Represents a ship's manifest of passengers and freight +use crate::trade::available_goods::AvailableGood; +use crate::trade::available_goods::AvailableGoodsTable; +use std::collections::HashMap; + +/// Represents a ship's manifest of passengers, freight, and trade goods /// -/// Tracks the number of passengers in each class and the indices of -/// freight lots being carried. Used to calculate total revenue for -/// a trading voyage between worlds. +/// Tracks the number of passengers in each class, the indices of +/// freight lots being carried, and speculative trade goods purchased. +/// Used to calculate total revenue for a trading voyage between worlds. #[derive(Debug, Clone, Default)] pub struct ShipManifest { /// Number of high passage passengers (luxury accommodations) @@ -23,6 +27,10 @@ pub struct ShipManifest { pub low_passengers: i32, /// Indices of freight lots from available freight being carried pub freight_lot_indices: Vec, + /// Trade goods purchased for speculation + pub trade_goods: Vec, + /// Planned sell amounts for goods (keyed by source_entry.index) + pub sell_plan: HashMap, } /// Revenue (in credits) per high passage passenger by distance (in parsecs) @@ -115,4 +123,253 @@ impl ShipManifest { let distance_index = distance.clamp(1, 6) as usize; FREIGHT_COST[distance_index] * self.freight_lot_indices.len() as i32 } + + /// Adds or updates a trade good in the manifest + /// + /// If the good already exists in the manifest (matched by source_entry.index), + /// updates its quantity. If the quantity becomes 0 or negative, removes the good. + /// If the good doesn't exist and quantity > 0, adds it to the manifest. + /// + /// # Arguments + /// + /// * `good` - The trade good to add or update + /// * `quantity` - The new quantity for this good in the manifest + /// + /// # Examples + /// + /// ``` + /// use worldgen::trade::ship_manifest::ShipManifest; + /// use worldgen::trade::available_goods::AvailableGood; + /// use worldgen::trade::table::{TradeTableEntry, Availability, Quantity}; + /// use std::collections::HashMap; + /// + /// let mut manifest = ShipManifest::default(); + /// let entry = TradeTableEntry { + /// index: 1, + /// name: "Test Good".to_string(), + /// availability: Availability::All, + /// quantity: Quantity { dice: 1, multiplier: 6 }, + /// base_cost: 1000, + /// purchase_dm: HashMap::new(), + /// sale_dm: HashMap::new(), + /// }; + /// let good = AvailableGood { + /// name: "Test Good".to_string(), + /// quantity: 10, + /// purchased: 0, + /// base_cost: 1000, + /// buy_cost: 1000, + /// buy_cost_comment: String::new(), + /// sell_price: None, + /// sell_price_comment: String::new(), + /// source_entry: entry, + /// }; + /// + /// // Add a good with quantity 5 + /// manifest.update_trade_good(&good, 5); + /// // Update the same good to quantity 3 + /// manifest.update_trade_good(&good, 3); + /// // Remove the good by setting quantity to 0 + /// manifest.update_trade_good(&good, 0); + /// ``` + pub fn update_trade_good(&mut self, good: &AvailableGood, quantity: i32) { + let index = good.source_entry.index; + // Find existing good by source entry index + if let Some(pos) = self + .trade_goods + .iter() + .position(|g| g.source_entry.index == index) + { + if quantity <= 0 { + // Remove the good if quantity is 0 or negative + self.trade_goods.remove(pos); + self.sell_plan.remove(&index); + } else { + // Update the existing good's quantity + let mut updated_good = good.clone(); + updated_good.purchased = quantity; + self.trade_goods[pos] = updated_good; + // Ensure sell plan defaults to zero, and clamp to available if previously set + let entry = self.sell_plan.entry(index).or_insert(0); + *entry = (*entry).min(quantity).max(0); + } + } else if quantity > 0 { + // Add new good if it doesn't exist and quantity > 0 + let mut new_good = good.clone(); + new_good.purchased = quantity; + self.trade_goods.push(new_good); + // Default sell plan to zero amount + self.sell_plan.insert(index, 0); + } + } + + /// Sets the planned sell amount for a given good (clamped to [0, purchased]) + pub fn set_sell_amount(&mut self, good: &AvailableGood, amount: i32) { + if let Some(existing) = self + .trade_goods + .iter() + .find(|g| g.source_entry.index == good.source_entry.index) + { + let clamped = amount.clamp(0, existing.purchased); + self.sell_plan.insert(good.source_entry.index, clamped); + } + } + + /// Returns the planned sell amount for a given good (defaults to purchased) + pub fn get_sell_amount(&self, good: &AvailableGood) -> i32 { + let purchased = self + .trade_goods + .iter() + .find(|g| g.source_entry.index == good.source_entry.index) + .map(|g| g.purchased) + .unwrap_or(0); + self.sell_plan + .get(&good.source_entry.index) + .copied() + .unwrap_or(0) + .min(purchased) + .max(0) + } + + /// Gets the quantity of a specific trade good in the manifest + /// + /// Returns the quantity of the specified good currently in the manifest, + /// or 0 if the good is not in the manifest. + pub fn get_trade_good_quantity(&self, good: &AvailableGood) -> i32 { + self.get_trade_good_quantity_by_index(good.source_entry.index) + } + + /// Gets the quantity of a specific trade good by its trade table index + pub fn get_trade_good_quantity_by_index(&self, index: i16) -> i32 { + self.trade_goods + .iter() + .find(|g| g.source_entry.index == index) + .map(|g| g.purchased) + .unwrap_or(0) + } + + /// Removes a trade good from the manifest by index, if present + pub fn remove_trade_good_by_index(&mut self, index: i16) { + if let Some(pos) = self.trade_goods.iter().position(|g| g.source_entry.index == index) { + self.trade_goods.remove(pos); + } + self.sell_plan.remove(&index); + } + + + /// Commits the planned sell amount for the given good index: + /// subtracts the planned amount from the manifest (down to 0), + /// removes the good if it reaches 0, and resets the sell plan to 0. + pub fn commit_sale_by_index(&mut self, index: i16) { + // Find the good + if let Some(pos) = self + .trade_goods + .iter() + .position(|g| g.source_entry.index == index) + { + let sell_amt = self.get_sell_amount_by_index(index); + if sell_amt <= 0 { + // Nothing to sell; just ensure plan is 0 + self.sell_plan.insert(index, 0); + return; + } + // Compute new quantity and update/remove + let current_qty = self.trade_goods[pos].purchased; + let new_qty = (current_qty - sell_amt).max(0); + if new_qty == 0 { + self.trade_goods.remove(pos); + } else if let Some(g) = self.trade_goods.get_mut(pos) { + g.purchased = new_qty; + } + // Reset plan + self.sell_plan.insert(index, 0); + } else { + // Not in manifest; clear any stale plan + self.sell_plan.remove(&index); + } + } + + + + /// Commits all planned sales across all goods in the manifest + pub fn commit_all_sales(&mut self) { + // Collect indices first to avoid borrow issues while mutating + let indices: Vec = self.trade_goods.iter().map(|g| g.source_entry.index).collect(); + for idx in indices { + self.commit_sale_by_index(idx); + } + } + + /// Calculates the total tonnage of trade goods in the manifest + /// + /// Returns the sum of all trade good quantities currently in the manifest. + /// + /// # Returns + /// + /// Total tonnage of trade goods + pub fn trade_goods_tonnage(&self) -> i32 { + self.trade_goods.iter().map(|g| g.purchased).sum() + } + + /// Calculates the total cost of trade goods in the manifest + /// + /// Returns the total purchase cost of all trade goods currently in the manifest. + /// + /// # Returns + /// + /// Total cost of trade goods in credits + pub fn trade_goods_cost(&self) -> i64 { + self.trade_goods + .iter() + .map(|g| g.purchased as i64 * g.buy_cost as i64) + .sum() + } + + /// Calculates the total potential proceeds from trade goods in the manifest + /// + /// Returns the total potential selling value of all trade goods currently + /// in the manifest, based on planned sell amounts if available. + /// + /// # Returns + /// + /// Total potential proceeds from trade goods in credits + pub fn trade_goods_proceeds(&self) -> i64 { + self.trade_goods + .iter() + .map(|g| { + if let Some(sell_price) = g.sell_price { + let to_sell = self + .sell_plan + .get(&g.source_entry.index) + .copied() + .unwrap_or(0) + .min(g.purchased) + .max(0); + to_sell as i64 * sell_price as i64 + } else { + 0 + } + }) + .sum() + } + + /// Set sell amount by trade table index (non-negative; UI is responsible for clamping) + pub fn set_sell_amount_by_index(&mut self, index: i16, amount: i32) { + let clamped = amount.max(0); + self.sell_plan.insert(index, clamped); + } + + /// Get sell amount by trade table index (defaults to 0 if unset) + pub fn get_sell_amount_by_index(&self, index: i16) -> i32 { + self.sell_plan.get(&index).copied().unwrap_or(0) + } + + /// Reset passengers and freight selections, preserving trade goods and sell plans + pub fn reset_passengers_and_freight(&mut self) { + self.high_passengers = 0; + self.medium_passengers = 0; + self.basic_passengers = 0; + self.low_passengers = 0; + self.freight_lot_indices.clear(); + } } From 620e8a1730f325a0a533c18ad59287f20e14d08a Mon Sep 17 00:00:00 2001 From: dcsturman Date: Tue, 2 Sep 2025 22:12:56 -0700 Subject: [PATCH 2/3] Add Manually implemented. --- index.html | 2 + public/css/modal.css | 128 +++++++++++++++++++++++++++++++ src/components/trade_computer.rs | 92 +++++++++++++++++++++- 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 public/css/modal.css diff --git a/index.html b/index.html index dc1dd24..5d35f19 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,8 @@ + + diff --git a/public/css/modal.css b/public/css/modal.css new file mode 100644 index 0000000..c7b7bc3 --- /dev/null +++ b/public/css/modal.css @@ -0,0 +1,128 @@ +:root { + --tg-modal-bg: #0b0f12; + --tg-modal-surface: #2e3136; /* neutral grey */ + --tg-modal-border: #42474d; + --tg-modal-text: #edf2f7; /* light but not stark white */ + --tg-modal-muted: #b7c0c9; + --tg-blue: #51A9EE; + --tg-blue-dark: #1976d2; + --tg-red: #ff5575; +} + +@keyframes tg-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes tg-slide-in { + from { opacity: 0; transform: translate(-50%, -6%); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +.tg-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + animation: tg-fade-in .18s ease-out both; +} + +.tg-modal-panel { + position: fixed; + z-index: 1001; + left: 50%; + top: 15%; + transform: translate(-50%, 0); + background: var(--tg-modal-surface); + color: var(--tg-modal-text); + border: 1px solid var(--tg-modal-border); + padding: 16px 16px 14px 16px; + border-radius: 10px; + min-width: 380px; + max-width: 92vw; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); + animation: tg-slide-in .22s cubic-bezier(.2,.7,.2,1) both; +} + +.tg-modal-panel h5 { + margin: 0 0 12px 0; + color: var(--tg-blue); + font-weight: 700; + letter-spacing: .25px; +} + +.tg-modal-panel .modal-body { + display: flex; + flex-direction: column; + gap: 10px; +} + +.tg-modal-panel .modal-label { + font-size: 11pt; + color: var(--tg-modal-muted); +} + +.tg-modal-panel select, +.tg-modal-panel input[type="number"], +.tg-modal-panel input[type="text"] { + appearance: none; + -webkit-appearance: none; + background: #0e151b; + border: 1px solid var(--tg-modal-border); + color: var(--tg-modal-text); + border-radius: 8px; + padding: 8px 10px; + font-family: "Space Mono", monospace; +} + +.tg-modal-panel select:focus, +.tg-modal-panel input:focus { + outline: none; + border-color: var(--tg-blue); + box-shadow: 0 0 0 2px rgba(81,169,238,.2); +} + +.tg-modal-panel input[type="number"]::-webkit-outer-spin-button, +.tg-modal-panel input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.tg-modal-panel .modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 14px; +} + +.tg-btn { + font-family: inherit; + font-size: 10pt; + padding: 7px 16px; + cursor: pointer; + transition: all .15s ease; +} + +.tg-btn-cancel { + background: transparent; + color: var(--tg-modal-muted); + border: 1px solid var(--tg-modal-border); + border-radius: 22px; /* match Done pill shape */ +} +.tg-btn-cancel:hover { color: var(--tg-blue); border-color: var(--tg-blue); } + +.tg-btn-done { + background: linear-gradient(135deg, #51A9EE 0%, #1976d2 100%); + color: white; + border: 1px solid #1976d2; + border-radius: 22px; + box-shadow: 0 2px 6px rgba(0,0,0,.3); +} +.tg-btn-done:hover { filter: brightness(1.05); } +.tg-btn-done:active { transform: translateY(1px); } + +.tg-error { + color: var(--tg-red); + font-size: 10pt; + margin-top: 4px; +} + diff --git a/src/components/trade_computer.rs b/src/components/trade_computer.rs index fe027fb..bb06861 100644 --- a/src/components/trade_computer.rs +++ b/src/components/trade_computer.rs @@ -294,6 +294,7 @@ pub fn Trade() -> impl IntoView { let distance = RwSignal::new(0); + // Keep origin world updated based on changes in name or uwp. Effect::new(move |_| { let name = origin_world_name.get(); @@ -1223,6 +1224,11 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView { let available_goods = expect_context::>(); let show_sell_price = expect_context::>() ; + // Dialog state for manually adding goods to manifest + let show_add_manual = RwSignal::new(false); + let manual_selected_index = RwSignal::new(11i16); + let manual_qty_input = RwSignal::new(String::new()); + let remove_high_passenger = move |_| { let mut manifest = ship_manifest.write(); if manifest.high_passengers > 0 { @@ -1364,7 +1370,11 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView {
-
"Trade Goods in Manifest"
+
"Trade Goods in Manifest" + +
{move || { let manifest = ship_manifest.get(); @@ -1400,6 +1410,8 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView { // Remove from manifest let mut manifest = ship_manifest.write(); manifest.remove_trade_good_by_index(good_index); + + drop(manifest); // Also reset the purchased amount in the available goods table so the input shows 0 let mut ag = available_goods.write(); @@ -1507,6 +1519,84 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView {
+ + +
+
+
"Add Trade Good"
+ + +
+
} From 0527f309f3e326e8bf66309b8402a1aec739598e Mon Sep 17 00:00:00 2001 From: dcsturman Date: Sun, 7 Sep 2025 16:27:59 -0700 Subject: [PATCH 3/3] Build 1.2 Includes changes to use session storage, add illegal goods, and formatting fixes. Should not have been done as 1 PR. --- Cargo.lock | 323 +++++- Cargo.toml | 6 +- notes/TODO_process_trades_placement.md | 6 + public/css/modal.css | 8 + src/components/system_generator.rs | 4 +- src/components/trade_computer.rs | 1353 ++++++++++++++---------- src/systems/astro.rs | 3 +- src/systems/system.rs | 2 +- src/systems/world.rs | 11 +- src/trade/available_goods.rs | 237 ++--- src/trade/available_passengers.rs | 53 +- src/trade/mod.rs | 20 +- src/trade/ship_manifest.rs | 162 ++- src/trade/table.rs | 18 + src/util.rs | 42 +- style.css | 478 +++++---- style.patch | 16 + 17 files changed, 1765 insertions(+), 977 deletions(-) create mode 100644 notes/TODO_process_trades_placement.md create mode 100644 style.patch diff --git a/Cargo.lock b/Cargo.lock index 5d04077..7a954d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.20" @@ -187,12 +202,36 @@ version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d07aa9a93b00c76f71bc35d598bed923f6d4f3a9ca5c24b7737ae1a292841c0" +[[package]] +name = "cc" +version = "1.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5252b3d2648e5eedbc1a6f501e3c795e07025c1e93bbf8bbdd6eef7f447a6d54" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "codee" version = "0.3.2" @@ -298,6 +337,23 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -323,6 +379,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -337,6 +428,27 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "default-struct-builder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive-where" version = "1.6.0" @@ -445,6 +557,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" + [[package]] name = "fnv" version = "1.0.7" @@ -595,6 +713,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -668,6 +798,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -754,6 +908,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -877,6 +1037,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "leptos-use" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6eac17e7d306b4ad67158aba97c1490884ba304add4321069cb63fe0834c3b1" +dependencies = [ + "cfg-if", + "chrono", + "codee", + "cookie", + "default-struct-builder", + "futures-util", + "gloo-timers", + "js-sys", + "lazy_static", + "leptos", + "paste", + "send_wrapper", + "thiserror 2.0.16", + "unic-langid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "leptos_config" version = "0.8.5" @@ -1069,6 +1254,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -1201,6 +1401,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1660,6 +1866,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.11" @@ -1687,6 +1899,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.106" @@ -1835,6 +2053,36 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "time" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1950,6 +2198,24 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -2202,12 +2468,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -2366,13 +2685,15 @@ dependencies = [ [[package]] name = "worldgen" -version = "1.0.0" +version = "1.2.0" dependencies = [ + "codee", "console_error_panic_hook", "getrandom", "itertools", "lazy_static", "leptos", + "leptos-use", "leptos_meta", "log", "rand", diff --git a/Cargo.toml b/Cargo.toml index 5bc0225..4d4d039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "worldgen" -version = "1.0.0" +version = "1.2.0" edition = "2021" license = "MIT" @@ -20,8 +20,10 @@ serde_json = "1.0" wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } wasm-bindgen-futures = "0.4" serde-wasm-bindgen = "0.6" -web-sys = { version = "0.3", features = ["Request", "RequestInit", "RequestMode", "Response", "Window", "Headers"] } +web-sys = { version = "0.3", features = ["Request", "RequestInit", "RequestMode", "Response", "Window", "Headers", "Storage"] } wasm-logger = "0.2" +leptos-use = { version = "0.16.2", features = ["storage"] } +codee = "0.3.2" [[bin]] name = "main" diff --git a/notes/TODO_process_trades_placement.md b/notes/TODO_process_trades_placement.md new file mode 100644 index 0000000..5f8a8c4 --- /dev/null +++ b/notes/TODO_process_trades_placement.md @@ -0,0 +1,6 @@ +- Fix placement: Process Trades + Profit should be rendered after the Total row, not before +- Re-add UI row with Process Trades + Profit in the Revenue section, after the Total span +- Ensure Goods Profit row always renders when show_sell_price is true; investigate the blank box issue +- Confirm Sell Qty input logic: no mutation of manifest quantities on change; only set sell_plan +- After fixes, cargo check and run app to visually verify layout + diff --git a/public/css/modal.css b/public/css/modal.css index c7b7bc3..d4b383e 100644 --- a/public/css/modal.css +++ b/public/css/modal.css @@ -43,6 +43,9 @@ animation: tg-slide-in .22s cubic-bezier(.2,.7,.2,1) both; } +.tg-modal-textstyle { + margin: 0 0 .5rem 0; +} .tg-modal-panel h5 { margin: 0 0 12px 0; color: var(--tg-blue); @@ -59,6 +62,7 @@ .tg-modal-panel .modal-label { font-size: 11pt; color: var(--tg-modal-muted); + margin-top: .5rem; } .tg-modal-panel select, @@ -94,6 +98,10 @@ margin-top: 14px; } +.tg-modal-error { + min-height: 1.2em; +} + .tg-btn { font-family: inherit; font-size: 10pt; diff --git a/src/components/system_generator.rs b/src/components/system_generator.rs index 8bdf49c..b23df60 100644 --- a/src/components/system_generator.rs +++ b/src/components/system_generator.rs @@ -184,7 +184,7 @@ fn print() { pub fn World() -> impl IntoView { // Initialize global context stores with default world and empty system provide_context(Store::new( - World::from_upp(INITIAL_NAME.to_string(), INITIAL_UPP, false, true).unwrap(), + World::from_upp(INITIAL_NAME, INITIAL_UPP, false, true).unwrap(), )); provide_context(Store::new(System::default())); @@ -206,7 +206,7 @@ pub fn World() -> impl IntoView { debug!("Building world {name} with UPP {upp}"); // Attempt to parse the UWP string into a world object - let Ok(mut w) = World::from_upp(name, upp.as_str(), false, true) else { + let Ok(mut w) = World::from_upp(&name, upp.as_str(), false, true) else { // If parsing fails, log error and bail out to prevent crashes log::error!("Failed to parse UWP in hook to build main world: {upp}"); return; diff --git a/src/components/trade_computer.rs b/src/components/trade_computer.rs index bb06861..2ed67b7 100644 --- a/src/components/trade_computer.rs +++ b/src/components/trade_computer.rs @@ -175,39 +175,31 @@ //! //! Includes print functionality for generating hard copies of trade data, //! though this feature is currently disabled but available for future use. - +use codee::string::JsonSerdeCodec; use leptos::prelude::*; -use reactive_stores::Store; +use leptos_use::storage::{ + use_session_storage, use_session_storage_with_options, UseStorageOptions, +}; +use std::collections::HashSet; #[allow(unused_imports)] -use leptos::leptos_dom::logging::console_log; - -use log::debug; +use log::{debug, error}; use crate::components::traveller_map::WorldSearch; use crate::systems::world::World; + use crate::trade::available_goods::{AvailableGood, AvailableGoodsTable}; -#[derive(Clone, Copy)] -struct BuyerBrokerSkillSignal(pub RwSignal); -#[derive(Clone, Copy)] -struct SellerBrokerSkillSignal(pub RwSignal); use crate::trade::available_passengers::AvailablePassengers; use crate::trade::ship_manifest::ShipManifest; use crate::trade::table::TradeTable; use crate::trade::ZoneClassification; +use crate::util::Mcr; + use crate::INITIAL_NAME; use crate::INITIAL_UPP; -/// Internal type for managing sell price display state -/// -/// Wraps a boolean flag indicating whether sell prices should be displayed -/// in the trade goods table. Used as a reactive store type for managing -/// the "Show Sell Price" toggle functionality. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -struct ShowSellPriceType(bool); - /// Main trade computer component providing comprehensive trading interface /// /// Creates the complete trade calculation interface including world selection, @@ -255,178 +247,228 @@ struct ShowSellPriceType(bool); #[component] pub fn Trade() -> impl IntoView { // The main world always exists (starts with a default value) and we use that type in the context. - provide_context(Store::new( - World::from_upp(INITIAL_NAME.to_string(), INITIAL_UPP, false, true).unwrap(), - )); + let (origin_world, write_origin_world, _) = + use_session_storage_with_options::( + "worldgen:origin_world:v1", + UseStorageOptions::default() + .initial_value(World::from_upp(INITIAL_NAME, INITIAL_UPP, false, true).unwrap()), + ); + // The destination world doesn't always exist - there is valid function w/o it. So its an Option and starts as value None. // Important to remember this as given the way Leptos_store works, this is the way you differentiate between the main world // and the destination world in the state. - provide_context(Store::new(None::)); - provide_context(Store::new(AvailableGoodsTable::new())); - provide_context(Store::new(None::)); - // Used for "show sell price" - provide_context(Store::new(ShowSellPriceType(false))); - provide_context(Store::new(ShipManifest::default())); - - let origin_world = expect_context::>(); - let dest_world = expect_context::>>(); - let trade_table = TradeTable::default(); - let available_goods = expect_context::>(); - let available_passengers = expect_context::>>(); - let show_sell_price = expect_context::>(); - let ship_manifest = expect_context::>(); + + let (dest_world, write_dest_world, _) = + use_session_storage::, JsonSerdeCodec>("worldgen:dest_world:v1"); + let (available_goods, write_available_goods, _) = + use_session_storage::("worldgen:available_goods:v1"); + let (available_passengers, write_available_passengers, _) = + use_session_storage::, JsonSerdeCodec>( + "worldgen:available_passengers:v1", + ); + let (show_sell_price, write_show_sell_price, _) = + use_session_storage::("worldgen:show_sell_price:v1"); + let (ship_manifest, write_ship_manifest, _) = + use_session_storage::("worldgen:manifest:v1"); // Skills involved, both player and adversary. - let buyer_broker_skill = RwSignal::new(0); - let seller_broker_skill = RwSignal::new(0); - provide_context(BuyerBrokerSkillSignal(buyer_broker_skill)); - provide_context(SellerBrokerSkillSignal(seller_broker_skill)); - let steward_skill = RwSignal::new(0); + let (buyer_broker_skill, write_buyer_broker_skill, _) = + use_session_storage::("worldgen:buyer_broker_skill:v1"); + let (seller_broker_skill, write_seller_broker_skill, _) = + use_session_storage::("worldgen:seller_broker_skill:v1"); + let (steward_skill, write_steward_skill, _) = + use_session_storage::("worldgen:steward_skill:v1"); + // Toggle for including illegal goods in market generation + let (illegal_goods, _write_illegal_goods, _) = + use_session_storage::("worldgen:illegal_goods:v1"); let origin_world_name = RwSignal::new(origin_world.read_untracked().name.clone()); let origin_uwp = RwSignal::new(origin_world.read_untracked().to_uwp()); + let origin_coords = RwSignal::new(origin_world.read_untracked().coordinates); let origin_zone = RwSignal::new(origin_world.read_untracked().travel_zone); - let dest_world_name = RwSignal::new("".to_string()); - let dest_uwp = RwSignal::new("".to_string()); - let dest_coords = RwSignal::new(None); - let dest_zone = RwSignal::new(ZoneClassification::Green); - + let dest_world_name = RwSignal::new( + dest_world + .read_untracked() + .as_ref() + .map(|w| w.name.clone()) + .unwrap_or_default(), + ); + let dest_uwp = RwSignal::new( + dest_world + .read_untracked() + .as_ref() + .map(|w| w.to_uwp()) + .unwrap_or_default(), + ); + + // Distance between worlds let distance = RwSignal::new(0); + let dest_coords = RwSignal::new( + dest_world + .read_untracked() + .as_ref() + .and_then(|w| w.coordinates), + ); + let dest_zone = RwSignal::new( + dest_world + .read_untracked() + .as_ref() + .map(|w| w.travel_zone) + .unwrap_or(ZoneClassification::Green), + ); + + // Closure used when we have to recalculate distance. Done as a closure as we need + // to access multiple signals within this component. + let calc_distance_closure = move || { + if let (Some(origin), Some(dest)) = (origin_coords.get(), dest_coords.get()) { + let calculated_distance = crate::components::traveller_map::calculate_hex_distance( + origin.0, origin.1, dest.0, dest.1, + ); + debug!("Calculated distance = {calculated_distance}"); + distance.set(calculated_distance); + } + }; // Keep origin world updated based on changes in name or uwp. - Effect::new(move |_| { + // If name or uwp changes, update origin_world. + Effect::new(move |prev: Option<(String, String)>| { + if let Some((prev_name, prev_uwp)) = &prev { + if *prev_name == origin_world_name.get() && *prev_uwp == origin_uwp.get() { + debug!("Origin world name and uwp haven't changed; skipping rebuild: {prev_name}, {prev_uwp}"); + return (prev_name.to_string(), prev_uwp.to_string()); + } + } + debug!( + "Rebuilding origin world as name or uwp changed. Was {:?}; Now name = {}, uwp = {} ", + &prev, + origin_world_name.get(), + origin_uwp.get() + ); + let name = origin_world_name.get(); let uwp = origin_uwp.get(); debug!("In first Effect: name = {name}, uwp = {uwp}"); if !name.is_empty() && uwp.len() == 9 { - let Ok(mut world) = World::from_upp(name, &uwp, false, false) else { + let Ok(mut world) = World::from_upp(&name, &uwp, false, false) else { log::error!("Failed to parse UPP in hook to build origin world: {uwp}"); - return; + return (name, uwp); }; world.gen_trade_classes(); world.coordinates = origin_coords.get(); world.travel_zone = origin_zone.get(); - origin_world.set(world); + write_origin_world.set(world); - // Now update available goods + // Now update available goods only after the first (restoration) pass let ag = AvailableGoodsTable::for_world( - &trade_table, + TradeTable::global(), &origin_world.read().get_trade_classes(), origin_world.read().get_population(), - false, + illegal_goods.get(), ) .unwrap(); - - available_goods.set(ag); + write_available_goods.set(ag); + calc_distance_closure(); + } else { + // If we don't have a valid name, reset other UI elements to reasonable defaults. + origin_zone.set(ZoneClassification::Green); + distance.set(0); } + (name, uwp) }); // Keep destination world updated based on changes in name or uwp. - Effect::new(move |_| { + // If name or uwp changes, update dest_world. + Effect::new(move |prev: Option<(String, String)>| { + if let Some((prev_name, prev_uwp)) = &prev { + if *prev_name == dest_world_name.get() && *prev_uwp == dest_uwp.get() { + debug!("Destination world name and uwp haven't changed; skipping rebuild: {prev_name}, {prev_uwp}"); + return (prev_name.to_string(), prev_uwp.to_string()); + } + } + debug!("Not just restored from storage so do rebuild of destination."); + let name = dest_world_name.get(); let uwp = dest_uwp.get(); + debug!("Rebuilding destination world as name or uwp changed."); if !name.is_empty() && uwp.len() == 9 { - let Ok(mut world) = World::from_upp(name, &uwp, false, false) else { + let Ok(mut world) = World::from_upp(&name, &uwp, false, false) else { log::error!("Failed to parse UPP in hook to build destination world: {uwp}"); - dest_world.set(None); - return; + write_dest_world.set(None); + return (name, uwp); }; world.gen_trade_classes(); world.coordinates = dest_coords.get(); world.travel_zone = dest_zone.get(); - dest_world.set(Some(world)); + write_dest_world.set(Some(world)); + calc_distance_closure(); } else { - dest_world.set(None); + // If we don't have a valid name, reset other UI elements to reasonable defaults. + write_dest_world.set(None); + dest_zone.set(ZoneClassification::Green); + distance.set(0); } + (name, uwp) }); + // On a change to origin_world and/or dest_world updating pricing on available goods. + // Those updates are based on RNG so even with the same inputs, will change frequently. Effect::new(move |_| { - console_log("Updating goods pricing"); - // Do not wipe the manifest; keep trade goods and sell plans across recalculations - let mut ag = available_goods.write(); - ag.price_goods_to_buy( - &origin_world.read().get_trade_classes(), - buyer_broker_skill.get(), - seller_broker_skill.get(), + let buyer = buyer_broker_skill.get(); + let supplier = seller_broker_skill.get(); + let distance = distance.get(); + let _illegal_goods = illegal_goods.get(); + let origin_world = origin_world.get(); + let dest_world = dest_world.get(); + + debug!( + "Updating goods pricing with origin world = {:?}", + origin_world ); + + // Do not wipe the manifest; keep trade goods and sell plans across recalculations + let mut ag = write_available_goods.write(); + ag.price_goods_to_buy(&origin_world.get_trade_classes(), buyer, supplier); ag.price_goods_to_sell( - dest_world.get().as_ref().map(|w| w.get_trade_classes()), - buyer_broker_skill.get(), - seller_broker_skill.get(), + dest_world.as_ref().map(|w| w.get_trade_classes()), + buyer, + supplier, ); ag.sort_by_discount(); // Also price goods currently in the manifest, even if not available in this market - let dest_classes_opt = dest_world.get().as_ref().map(|w| w.get_trade_classes()); - let buyer = buyer_broker_skill.get(); - let supplier = seller_broker_skill.get(); - let mut manifest = ship_manifest.write(); + let dest_classes_opt = dest_world.as_ref().map(|w| w.get_trade_classes()); + let mut manifest = write_ship_manifest.write(); let mut rng = rand::rng(); for g in &mut manifest.trade_goods { g.price_to_sell_rng(dest_classes_opt.as_deref(), buyer, supplier, &mut rng); } - }); - - // Effect to reset show_sell_price when either origin or destination world changes. - Effect::new(move |_| { - let _ = origin_world.get(); - let _ = dest_world.get(); - show_sell_price.set(ShowSellPriceType(false)); - // Preserve trade goods while resetting only passengers and freight - let mut manifest = ship_manifest.write(); - manifest.reset_passengers_and_freight(); - }); - - // Effect to reset zones when world names change (but not when zones change) - Effect::new(move |_| { - let _ = origin_world_name.get(); - origin_zone.set(ZoneClassification::Green); - }); - - Effect::new(move |_| { - let _ = dest_world_name.get(); - dest_zone.set(ZoneClassification::Green); - }); - // Effect to calculate distance when coordinates or zone change - Effect::new(move |_| { - if let (Some(origin), Some(dest)) = (origin_coords.get(), dest_coords.get()) { - debug!( - "Calculating distance ({},{}) to ({},{}).", - origin.0, origin.1, dest.0, dest.1 - ); - let calculated_distance = crate::components::traveller_map::calculate_hex_distance( - origin.0, origin.1, dest.0, dest.1, - ); - console_log(format!("Calculated distance: {calculated_distance}").as_str()); - distance.set(calculated_distance); - } - }); + write_show_sell_price.set(false); - // Effect to update passengers when destination world, distance, or steward skill changes - Effect::new(move |_| { - if let Some(world) = dest_world.get() { - if distance.get() > 0 { - available_passengers.set(Some(AvailablePassengers::generate( - origin_world.read().get_population(), - origin_world.read().port, - origin_world.read().travel_zone, - origin_world.read().tech_level, + // Calculate passengers. + if let Some(world) = dest_world { + if distance > 0 { + write_available_passengers.set(Some(AvailablePassengers::generate( + origin_world.get_population(), + origin_world.port, + origin_world.travel_zone, + origin_world.tech_level, world.get_population(), world.port, world.travel_zone, world.tech_level, - distance.get(), - steward_skill.get(), + distance, + i32::from(steward_skill.get()), + i32::from(buyer_broker_skill.get()), ))); } else { - available_passengers.set(None); + write_available_passengers.set(None); } } else { - available_passengers.set(None); + write_available_passengers.set(None); } }); @@ -472,7 +514,13 @@ pub fn Trade() -> impl IntoView {
"Origin Classes: " - {move || format!("[{}] {}", origin_world.read().trade_classes_string(), origin_world.read().travel_zone)} + {move || { + format!( + "[{}] {}", + origin_world.read().trade_classes_string(), + origin_world.read().travel_zone, + ) + }}
@@ -482,7 +530,7 @@ pub fn Trade() -> impl IntoView { format!( "Destination Trade Classes: [{}] {}", world.trade_classes_string(), - world.travel_zone + world.travel_zone, ) } else { "".to_string() @@ -501,7 +549,8 @@ pub fn Trade() -> impl IntoView { max="100" value=move || buyer_broker_skill.get() on:change=move |ev| { - buyer_broker_skill + debug!("Setting buyer broker skill to {}", event_target_value(&ev)); + write_buyer_broker_skill .set(event_target_value(&ev).parse().unwrap_or(0)); } /> @@ -515,7 +564,7 @@ pub fn Trade() -> impl IntoView { max="100" value=move || seller_broker_skill.get() on:change=move |ev| { - seller_broker_skill + write_seller_broker_skill .set(event_target_value(&ev).parse().unwrap_or(0)); } /> @@ -529,15 +578,59 @@ pub fn Trade() -> impl IntoView { max="100" value=move || steward_skill.get() on:change=move |ev| { - steward_skill.set(event_target_value(&ev).parse().unwrap_or(0)); + write_steward_skill + .set(event_target_value(&ev).parse().unwrap_or(0)); } />
+
+
+ + +
+
+ - - + + } @@ -560,6 +653,150 @@ fn print() { .unwrap_or_else(|e| log::error!("Error printing: {e:?}")); } +/// Row component for displaying the row in the speculative goods table for a single good. +/// +/// This can be in one of two modes: where we are showing sale prices, or we are not +/// as defined by `show_sell_price`. +/// +/// # Arguments +/// +/// * `good` - The good to display +/// * `write_available_goods` - Write signal for the available goods table +/// * `ship_manifest` - Signal for the ship manifest +/// * `write_ship_manifest` - Write signal for the ship manifest +/// * `show_sell_price` - Signal for whether to show sell prices +#[component] +pub fn SpecGoodRow( + good: AvailableGood, + write_available_goods: WriteSignal, + ship_manifest: Signal, + write_ship_manifest: WriteSignal, + show_sell_price: Signal, +) -> impl IntoView { + // Closure to handle changes in the amount purchased input. + let update_purchased = move |ev| { + let new_value = event_target_value(&ev).parse::().unwrap_or(0); + let mut ag = write_available_goods.write(); + let mut manifest = write_ship_manifest.write(); + let good_index = good.source_index; + if let Some(good) = ag.goods.iter_mut().find(|g| g.source_index == good_index) { + let clamped_value = new_value.clamp(0, good.quantity); + good.purchased = clamped_value; + manifest.update_trade_good(good, clamped_value); + } + }; + + let discount_percent = (good.buy_cost as f64 / good.base_cost as f64 * 100.0).round() as i32; + let buy_cost_comment = good.buy_cost_comment.clone(); + let sell_price_comment = good.sell_price_comment.clone(); + let manifest_quantity = ship_manifest.read().get_trade_good_quantity(&good); + let available_quantity = (good.quantity - manifest_quantity).max(0); + let carried_badge = manifest_quantity > 0; + + view! { + + + {good.name.clone()} + + "carried" + + + {available_quantity.to_string()} + {good.base_cost.to_string()} + + {good.buy_cost.to_string()} + + {discount_percent.to_string()}"%" + + 0 { + "purchased-input purchased-input-active" + } else { + "purchased-input" + } + } + /> + + + + {if let Some(sell_price) = good.sell_price { + let sell_discount_percent = (sell_price as f64 / good.base_cost as f64 * 100.0) + .round() as i32; + + view! { + + {sell_price.to_string()} + + {sell_discount_percent.to_string()}"%" + + { + let good_index = good.source_index; + let sell_edit = RwSignal::new( + ship_manifest.read().get_sell_amount_by_index(good_index), + ); + view! { + () + .unwrap_or(0); + let m = ship_manifest.read(); + let current_qty = m + .get_trade_good_quantity_by_index(good_index); + sell_edit.set(requested.clamp(0, current_qty)); + } + on:change=move |_| { + let new_amt = sell_edit.get(); + let mut manifest = write_ship_manifest.write(); + let current_qty = manifest + .get_trade_good_quantity_by_index(good_index); + let clamped = new_amt.clamp(0, current_qty); + manifest.set_sell_amount_by_index(good_index, clamped); + sell_edit.set(clamped); + } + class=move || { + if sell_edit.get() > 0 { + "purchased-input purchased-input-active" + } else { + "purchased-input" + } + } + /> + } + } + + } + .into_any() + } else { + view! { + "-" + "-" + ().into_any() + } + .into_any() + }} + + + + } + .into_any() +} + /// Trade view component displaying available goods and market information /// /// Renders the market interface showing available trade goods with pricing, @@ -604,27 +841,57 @@ fn print() { /// Complete market interface with conditional sections based on /// destination world availability and current market conditions. #[component] -pub fn TradeView() -> impl IntoView { - let origin_world = expect_context::>(); - let dest_world = expect_context::>>(); - - let available_goods = expect_context::>(); - let available_passengers = expect_context::>>(); - let ship_manifest = expect_context::>(); - - let show_sell_price = expect_context::>(); - +pub fn TradeView( + origin_world: Signal, + dest_world: Signal>, + buyer_broker_skill: Signal, + seller_broker_skill: Signal, + available_goods: Signal, + write_available_goods: WriteSignal, + available_passengers: Signal>, + ship_manifest: Signal, + write_ship_manifest: WriteSignal, + show_sell_price: Signal, + write_show_sell_price: WriteSignal, +) -> impl IntoView { view! {
-

- "Trade Goods for " {move || origin_world.read().name.clone()} " [" - {move || origin_world.read().trade_classes_string()}"]" +

+ "Trade Goods for " {move || origin_world.read().name.clone()} + + " [" {move || origin_world.read().trade_classes_string()} "]" + + + {move || { + if let Some(dw) = dest_world.get() { + view! { + + " -> " {dw.name.clone()} + + " ["{dw.trade_classes_string()}"]" + + + + } + .into_any() + } else { + ().into_any() + } + }} +

+ - + -

"Speculation Goods"

+

"Speculation Goods"

{move || { @@ -638,7 +905,8 @@ pub fn TradeView() -> impl IntoView { - }.into_any() + } + .into_any() } else { view! { @@ -648,22 +916,21 @@ pub fn TradeView() -> impl IntoView { - + - + - }.into_any() + } + .into_any() } }} {move || { - let manifest_has_goods = !ship_manifest.read().trade_goods.is_empty(); - if available_goods.read().is_empty() && !manifest_has_goods { + let mut rng = rand::rng(); + if available_goods.read().is_empty() + && !ship_manifest.read().trade_goods.is_empty() + { view! { - + - }.into_any() + } + .into_any() } else { - let avail = available_goods.read(); - let goods_vec = avail.goods().to_vec(); - use std::collections::HashSet; - let avail_index_set: HashSet = goods_vec.iter().map(|g| g.source_entry.index).collect(); - let manifest_snapshot = ship_manifest.read(); - - // Capture pricing context - let dest_classes_opt = dest_world.get().as_ref().map(|w| w.get_trade_classes()); - let buyer = expect_context::().0.get(); - let supplier = expect_context::().0.get(); - let mut rng = rand::rng(); - - // Goods carried but not in available list: sanitize so Buy Qty shows 0 and Available is 0 - let mut manifest_only: Vec = manifest_snapshot - .trade_goods + let goods_vec = available_goods.read().goods().to_vec(); + let avail_index_set: HashSet = goods_vec .iter() - .filter(|g| !avail_index_set.contains(&g.source_entry.index)) - .map(|mg| { - let mut g = mg.clone(); - g.quantity = 0; // not available to buy here - g.purchased = 0; // do not mirror manifest quantity into Buy Qty - g.buy_cost = g.base_cost; // neutralize discount display - g.buy_cost_comment.clear(); - // Ensure we have a sell price for display - g.price_to_sell_rng(dest_classes_opt.as_deref(), buyer, supplier, &mut rng); - g - }) + .map(|g| g.source_index) .collect(); - - // Add goods that are no longer in manifest and not available, but still have a planned sell amount (>0) - let planned_only: Vec = manifest_snapshot - .sell_plan - .iter() - .filter_map(|(idx, amt)| if *amt > 0 { - // Only synthesize if not available and not in manifest - let in_available = avail_index_set.contains(idx); - let in_manifest = manifest_snapshot.trade_goods.iter().any(|g| g.source_entry.index == *idx); - if in_available || in_manifest { + let manifest_goods: Vec = ship_manifest + .read() + .manifest_goods_list() + .into_iter() + .filter_map(|mut g| { + if avail_index_set.contains(&g.source_index) { None } else { - // Rehydrate from trade table - TradeTable::default().get(*idx).map(|entry| { - let mut g = AvailableGood { - name: entry.name.clone(), - quantity: 0, - purchased: 0, - base_cost: entry.base_cost, - buy_cost: entry.base_cost, - buy_cost_comment: String::new(), - sell_price: None, - sell_price_comment: String::new(), - source_entry: entry.clone(), - }; - g.price_to_sell_rng(dest_classes_opt.as_deref(), buyer, supplier, &mut rng); - g - }) + let classes = dest_world + .read() + .as_ref() + .map(|w| w.get_trade_classes()); + g.price_to_sell_rng( + classes.as_deref(), + buyer_broker_skill.get(), + seller_broker_skill.get(), + &mut rng, + ); + Some(g) } - } else { None }) + }) .collect(); - drop(manifest_snapshot); let mut combined: Vec = goods_vec .into_iter() - .chain(manifest_only.into_iter()) - .chain(planned_only.into_iter()) + .chain(manifest_goods) .collect(); - combined.sort_by_key(|g| g.source_entry.index); - combined.into_iter().map(|good| { - let discount_percent = (good.buy_cost as f64 / good.base_cost as f64 - * 100.0) - .round() as i32; - - let purchased_amount = good.purchased; - let buy_cost_comment = good.buy_cost_comment.clone(); - let sell_price_comment = good.sell_price_comment.clone(); - - // Calculate available quantity (total - amount already in manifest), clamp at 0 - let manifest_quantity = ship_manifest.read().get_trade_good_quantity(&good); - let available_quantity = (good.quantity - manifest_quantity).max(0); - // Badge if carried in manifest - let carried_badge = manifest_quantity > 0; - - let update_purchased = move |ev| { - let new_value = event_target_value(&ev).parse::().unwrap_or(0); - let mut ag = available_goods.write(); - let mut manifest = ship_manifest.write(); - let good_index = good.source_entry.index; - if let Some(good) = ag.goods.iter_mut().find(|g| g.source_entry.index == good_index) { - // The max available is simply the total quantity of the good - - let good_index = good.source_entry.index; - - // The manifest will be updated to reflect the new amount - let clamped_value = new_value.clamp(0, good.quantity); - good.purchased = clamped_value; - // Update the ship manifest with the new quantity - manifest.update_trade_good(good, clamped_value); - } - }; - - if let Some(sell_price) = good.sell_price { - let sell_discount_percent = (sell_price as f64 - / good.base_cost as f64 * 100.0) - .round() as i32; - view! { - - - - - - - - - - - - - - }.into_any() - } else { - view! { - - - - - - - - - - - - - }.into_any() + combined + .sort_by(|a, b| { + let a_ratio = a.buy_cost as f64 / a.base_cost as f64; + let b_ratio = b.buy_cost as f64 / b.base_cost as f64; + a_ratio + .partial_cmp(&b_ratio) + .unwrap_or(std::cmp::Ordering::Equal) + }); + combined + .into_iter() + .map(|good| { + // For each good, show the row displaying it. + view! { + } }) .collect::>() @@ -959,7 +1053,6 @@ pub fn TradeView() -> impl IntoView { /// ### Freight Buttons /// - Toggle freight lot selection on/off /// - Visual indication of selected freight lots - /// - Prevents double-booking of freight lots /// /// ## Reactive Calculations @@ -986,15 +1079,16 @@ pub fn TradeView() -> impl IntoView { /// Interactive passenger and freight booking interface with real-time /// availability updates and manifest integration. #[component] -fn PassengerView() -> impl IntoView { - let available_passengers = expect_context::>>(); - let ship_manifest = expect_context::>(); - +fn PassengerView( + available_passengers: Signal>, + ship_manifest: Signal, + write_ship_manifest: WriteSignal, +) -> impl IntoView { let add_high_passenger = move |_| { if let Some(passengers) = available_passengers.get() { let remaining = passengers.high - ship_manifest.read().high_passengers; if remaining > 0 { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); manifest.high_passengers += 1; } } @@ -1004,7 +1098,7 @@ fn PassengerView() -> impl IntoView { if let Some(passengers) = available_passengers.get() { let remaining = passengers.medium - ship_manifest.read().medium_passengers; if remaining > 0 { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); manifest.medium_passengers += 1; } } @@ -1014,7 +1108,7 @@ fn PassengerView() -> impl IntoView { if let Some(passengers) = available_passengers.get() { let remaining = passengers.basic - ship_manifest.read().basic_passengers; if remaining > 0 { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); manifest.basic_passengers += 1; } } @@ -1024,14 +1118,14 @@ fn PassengerView() -> impl IntoView { if let Some(passengers) = available_passengers.get() { let remaining = passengers.low - ship_manifest.read().low_passengers; if remaining > 0 { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); manifest.low_passengers += 1; } } }; view! { -

"Available Passengers"

+

"Available Passengers"

-

"Available Freight (tons)"

+

"Available Freight (tons)"

{move || { if let Some(passengers) = available_passengers.get() { @@ -1103,7 +1197,7 @@ fn PassengerView() -> impl IntoView { .map(|(index, lot)| { let lot_size = lot.size; let toggle_freight = move |_| { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); if let Some(pos) = manifest .freight_lot_indices .iter() @@ -1218,66 +1312,109 @@ fn PassengerView() -> impl IntoView { /// Complete ship manifest interface with interactive controls and /// comprehensive revenue analysis for the planned voyage. #[component] -fn ShipManifestView(distance: RwSignal) -> impl IntoView { - let ship_manifest = expect_context::>(); - let available_passengers = expect_context::>>(); - let available_goods = expect_context::>(); - let show_sell_price = expect_context::>() ; - +fn ShipManifestView( + distance: RwSignal, + ship_manifest: Signal, + write_ship_manifest: WriteSignal, + write_available_goods: WriteSignal, + available_passengers: Signal>, + show_sell_price: Signal, + write_show_sell_price: WriteSignal, +) -> impl IntoView { // Dialog state for manually adding goods to manifest let show_add_manual = RwSignal::new(false); let manual_selected_index = RwSignal::new(11i16); + // Persist Profit (accumulated) in localStorage and load on startup + { + // Load from localStorage + let win = leptos::leptos_dom::helpers::window(); + if let Ok(Some(ls)) = win.local_storage() { + if let Ok(Some(s)) = ls.get_item("worldgen:profit:v1") { + if let Ok(p) = s.parse::() { + write_ship_manifest.write().profit = p; + } + } + } + // Save profit on manifest change + + Effect::new(move |_| { + let profit = ship_manifest.read().profit; + let win = leptos::leptos_dom::helpers::window(); + if let Ok(Some(ls)) = win.local_storage() { + let _ = ls.set_item("worldgen:profit:v1", &profit.to_string()); + } + }); + } + let manual_qty_input = RwSignal::new(String::new()); let remove_high_passenger = move |_| { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); if manifest.high_passengers > 0 { manifest.high_passengers -= 1; } }; let remove_medium_passenger = move |_| { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); if manifest.medium_passengers > 0 { manifest.medium_passengers -= 1; } }; let remove_basic_passenger = move |_| { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); if manifest.basic_passengers > 0 { manifest.basic_passengers -= 1; } }; let remove_low_passenger = move |_| { - let mut manifest = ship_manifest.write(); + let mut manifest = write_ship_manifest.write(); if manifest.low_passengers > 0 { manifest.low_passengers -= 1; } }; + let on_reset = move |_| { + // Confirm reset + let win = leptos::leptos_dom::helpers::window(); + let proceed = win + .confirm_with_message( + "Reset manifest? This will clear passengers, freight, trade goods, and sell plans.", + ) + .unwrap_or(false); + if !proceed { + return; + } + + // Clear manifest and persisted storage, and clear purchased amounts in available goods + write_ship_manifest.set(ShipManifest::default()); + + // Zero out purchased in available goods so Buy inputs show 0 + let mut ag = write_available_goods.write(); + for g in ag.goods.iter_mut() { + g.purchased = 0; + } + + write_show_sell_price.set(false); + }; + view! { -
-

"Ship Manifest"

+
+

"Ship Manifest"

{move || { - let passengers = available_passengers.get(); let manifest = ship_manifest.get(); - - let cargo_tons = if let Some(passengers) = passengers { - manifest.freight_lot_indices - .iter() - .map(|&index| passengers.freight_lots.get(index).map(|lot| lot.size).unwrap_or(0)) - .sum::() - } else { - 0 - }; - + let cargo_tons = available_passengers + .read() + .as_ref() + .map(|p| manifest.total_freight_tons(p)) + .unwrap_or(0); let goods_tons: i32 = manifest.trade_goods_tonnage(); let total_cargo = cargo_tons + goods_tons; - let total_passengers = manifest.high_passengers + manifest.medium_passengers + manifest.basic_passengers; + let total_passengers = manifest.total_passengers_not_low(); let total_low = manifest.low_passengers; view! { @@ -1285,6 +1422,9 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView { "Total Cargo Used: " {total_cargo.to_string()}" tons" " | Total Passengers: " {total_passengers.to_string()} " | Total Low: " {total_low.to_string()} +
} }} @@ -1324,20 +1464,14 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView {
"Freight"
{move || { - let passengers = available_passengers.get(); let manifest = ship_manifest.get(); - let show_sell = show_sell_price.get().0; - - let cargo_tons = if let Some(passengers) = passengers { - manifest.freight_lot_indices - .iter() - .map(|&index| passengers.freight_lots.get(index).map(|lot| lot.size).unwrap_or(0)) - .sum::() - } else { - 0 - }; - + let cargo_tons = available_passengers + .read() + .as_ref() + .map(|p| manifest.total_freight_tons(p)) + .unwrap_or(0); let goods_tons: i32 = manifest.trade_goods_tonnage(); + let show_sell = show_sell_price.get(); let goods_cost: i64 = manifest.trade_goods_cost(); let goods_proceeds: i64 = manifest.trade_goods_proceeds(); @@ -1352,15 +1486,20 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView {
"Goods Cost:" - {format!("{:.2} MCr", goods_cost as f64 / 1_000_000.0)} + + {format!("{:.2} MCr", Mcr::from(goods_cost))} +
{if show_sell { view! {
"Goods Proceeds:" - {format!("{:.2} MCr", goods_proceeds as f64 / 1_000_000.0)} + + {format!("{:.2} MCr", Mcr::from(goods_proceeds))} +
- }.into_any() + } + .into_any() } else { ().into_any() }} @@ -1370,89 +1509,109 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView {
-
"Trade Goods in Manifest" -
{move || { let manifest = ship_manifest.get(); - let show_sell = show_sell_price.get().0; - + let show_sell = show_sell_price.get(); if manifest.trade_goods.is_empty() { + view! {
"No trade goods in manifest"
- }.into_any() + } + .into_any() } else { let goods = manifest.trade_goods.clone(); - goods.into_iter().map(|good| { - // Reflect remaining quantity after planned sell amount - let _sell_amt = ship_manifest.read().get_sell_amount_by_index(good.source_entry.index); - - let cost = good.purchased as i64 * good.buy_cost as i64; - let proceeds = if let Some(sell_price) = good.sell_price { - good.purchased as i64 * sell_price as i64 - } else { - 0 - }; - let _profit = proceeds - cost; - - view! { -
- - {format!("{} ({}t):", good.name, ship_manifest.read().get_trade_good_quantity_by_index(good.source_entry.index))} - - - {move || { - let sell_amt = ship_manifest.read().get_sell_amount_by_index(good.source_entry.index); - if show_sell && good.sell_price.is_some() { - let proceeds = sell_amt as i64 * good.sell_price.unwrap() as i64; - let profit = proceeds - (sell_amt as i64 * good.buy_cost as i64); - format!("sell {}t → {:.2} MCr ({:+.2})", - sell_amt, - proceeds as f64 / 1_000_000.0, - profit as f64 / 1_000_000.0) - } else { - let remaining = ship_manifest.read().get_trade_good_quantity_by_index(good.source_entry.index); - format!("holding {}t @ {:.2} MCr", - remaining, - (remaining as i64 * good.buy_cost as i64) as f64 / 1_000_000.0) + goods + .into_iter() + .map(|good| { + // Reflect remaining quantity after planned sell amount + view! { +
+
+
- } - }).collect::>().into_any() + > + + X + + +
+
+ + {format!( + "{} ({}t):", + good.name, + ship_manifest + .read() + .get_trade_good_quantity_by_index(good.source_index), + )} + +
+ + + {move || { + let sell_amt = ship_manifest + .read() + .get_sell_amount_by_index(good.source_index); + if show_sell && good.sell_price.is_some() { + let proceeds = sell_amt as i64 + * good.sell_price.unwrap() as i64; + let profit = proceeds + - (sell_amt as i64 * good.buy_cost as i64); + format!( + "sell {}t → {:.2} MCr ({:+.2})", + sell_amt, + Mcr::from(proceeds), + Mcr::from(profit), + ) + } else { + let remaining = ship_manifest + .read() + .get_trade_good_quantity_by_index(good.source_index); + format!( + "holding {}t @ {:.2} MCr", + remaining, + Mcr::from(remaining * good.buy_cost), + ) + } + }} + + +
+
+
+ } + }) + .collect::>() + .into_any() } }}
@@ -1481,7 +1640,7 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView { }}
- +
"Goods Profit:" @@ -1500,11 +1659,11 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView { {move || { let manifest = ship_manifest.get(); - let show_sell = show_sell_price.get().0; - - let passenger_revenue = manifest.passenger_revenue(distance.get()) as i64; - let freight_revenue = manifest.freight_revenue(distance.get()) as i64; - + let show_sell = show_sell_price.get(); + let passenger_revenue = manifest.passenger_revenue(distance.get()) + as i64; + let freight_revenue = manifest.freight_revenue(distance.get()) + as i64; let goods_profit = if show_sell { let cost = manifest.trade_goods_cost(); let proceeds = manifest.trade_goods_proceeds(); @@ -1512,91 +1671,139 @@ fn ShipManifestView(distance: RwSignal) -> impl IntoView { } else { 0 }; - let total = passenger_revenue + freight_revenue + goods_profit; format!("{:.2} MCr", total as f64 / 1_000_000.0) }}
-
- - -
-
-
"Add Trade Good"
- - + + +
+
+
"Add Trade Good"
+ +
-
-
+
} diff --git a/src/systems/astro.rs b/src/systems/astro.rs index cbb5754..2213815 100644 --- a/src/systems/astro.rs +++ b/src/systems/astro.rs @@ -29,6 +29,7 @@ //! let astro_data = AstroData::compute(&star, &world); //! let description = astro_data.get_astro_description(&world); //! ``` +use serde::{Deserialize, Serialize}; use crate::systems::system::Star; use crate::systems::system_tables::{ @@ -58,7 +59,7 @@ const EARTH_TEMP: f32 = 288.0; /// including orbital characteristics, surface conditions, and atmospheric data. /// This data is used for generating realistic world descriptions and determining /// habitability and environmental conditions. -#[derive(Debug, Clone, PartialEq, Default)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] pub struct AstroData { /// Orbital period in Earth years orbital_period: f32, diff --git a/src/systems/system.rs b/src/systems/system.rs index 277823b..eba412b 100644 --- a/src/systems/system.rs +++ b/src/systems/system.rs @@ -1060,7 +1060,7 @@ mod tests { #[test_log::test] fn test_generate_system() { let main_upp = "A788899-A"; - let main_world = World::from_upp("Main World".to_string(), main_upp, false, true).unwrap(); + let main_world = World::from_upp("Main World", main_upp, false, true).unwrap(); let system = System::generate_system(main_world); println!("{system}"); diff --git a/src/systems/world.rs b/src/systems/world.rs index 30f96c0..11ca887 100644 --- a/src/systems/world.rs +++ b/src/systems/world.rs @@ -5,6 +5,7 @@ //! facilities, and trade classifications. //! use log::debug; use reactive_stores::Store; +use serde::{Deserialize, Serialize}; use std::fmt::Display; #[allow(unused_imports)] @@ -24,7 +25,7 @@ use crate::trade::ZoneClassification; /// Container for world satellites /// /// Stores a vector of satellite worlds with a key based on the parent world's name. -#[derive(Debug, Clone, Store, PartialEq)] +#[derive(Debug, Clone, Store, PartialEq, Serialize, Deserialize, Default)] pub struct World { pub name: String, pub orbit: usize, @@ -48,7 +49,7 @@ pub struct World { } /// Enum for facilities that can be present on a world -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Facility { Naval, Scout, @@ -62,7 +63,7 @@ pub enum Facility { /// Container for world (or gas giant) satellites /// each satellite is in its own right a world, though /// orbit numbering uses a different system than in the main system. -#[derive(Debug, Clone, Store, PartialEq)] +#[derive(Debug, Clone, Store, PartialEq, Serialize, Deserialize, Default)] pub struct Satellites { #[store(key: String = |world| world.name.clone())] pub sats: Vec, @@ -212,7 +213,7 @@ impl World { /// * `is_mainworld` - Whether this is the main world of the system used to just record in the returned world. It does not /// impact parsing of the UWP. pub fn from_upp( - name: String, + name: &str, upp: &str, is_satellite: bool, is_mainworld: bool, @@ -226,7 +227,7 @@ impl World { let law_level = i32::from_str_radix(&upp[6..7], 16)?; let tech_level = i32::from_str_radix(&upp[8..9], 16)?; let mut world = World::new( - name, + name.to_string(), 0, 0, size, diff --git a/src/trade/available_goods.rs b/src/trade/available_goods.rs index 30daec7..3a70444 100644 --- a/src/trade/available_goods.rs +++ b/src/trade/available_goods.rs @@ -73,6 +73,7 @@ //! This example demonstrates how to create a trade market for a world with specific trade classes and population, and then price the goods based on broker skills. use rand::Rng; +use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter, Result as FmtResult}; #[allow(unused_imports)] @@ -103,7 +104,7 @@ use crate::trade::TradeClass; /// Each good maintains a reference to its source trade table entry, allowing /// access to trade DMs, availability restrictions, and other metadata needed /// for advanced trade calculations. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct AvailableGood { /// Name of the good pub name: String, @@ -121,8 +122,8 @@ pub struct AvailableGood { pub sell_price: Option, /// Comment on the sell price, used with hover. pub sell_price_comment: String, - /// Original trade table entry this good was derived from - pub source_entry: TradeTableEntry, + /// Index into the trade table for this good. + pub source_index: i16, } impl Display for AvailableGood { @@ -163,7 +164,7 @@ impl Display for AvailableGood { /// - **Sorting**: Goods can be sorted by discount percentage /// - **Lookup**: Fast access to specific goods by trade table index /// - **Display**: Human-readable market summaries with pricing information -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct AvailableGoodsTable { /// List of available goods pub goods: Vec, @@ -314,9 +315,8 @@ impl AvailableGoodsTable { for _ in 0..dice_count { total += rng.random_range(1..=6); } - let mut quantity = total * multiplier; - quantity += if world_population <= 3 { + total += if world_population <= 3 { -3 } else if world_population >= 9 { 3 @@ -324,11 +324,18 @@ impl AvailableGoodsTable { 0 }; + let quantity = total * multiplier; + + // If we ended up with no quantity, we don't add this to the list. + if quantity <= 0 { + return Ok(()); + } + // If the good is already in the table, add to its quantity if let Some(existing) = self .goods .iter_mut() - .find(|g| g.source_entry.index == entry.index) + .find(|g| g.source_index == entry.index) { existing.quantity += quantity; return Ok(()); @@ -344,7 +351,7 @@ impl AvailableGoodsTable { buy_cost_comment: String::default(), sell_price: None, sell_price_comment: String::default(), - source_entry: entry, + source_index: entry.index, }; self.goods.push(good); @@ -363,7 +370,7 @@ impl AvailableGoodsTable { /// Get a specific good by its index pub fn get_by_index(&self, index: i16) -> Option<&AvailableGood> { - self.goods.iter().find(|g| g.source_entry.index == index) + self.goods.iter().find(|g| g.source_index == index) } /// Get all available goods @@ -436,19 +443,11 @@ impl AvailableGoodsTable { /// # use worldgen::trade::table::{Availability, Quantity}; /// # use worldgen::trade::table::TradeTableEntry; /// let mut market = AvailableGoodsTable::new(); - /// // Add some goods to the market - /// market.add_entry(TradeTableEntry { - /// index: 1, - /// name: "Good 1".to_string(), - /// availability: Availability::All, - /// quantity: Quantity { dice: 2, multiplier: 1 }, - /// base_cost: 10000, - /// purchase_dm: vec![(TradeClass::Agricultural, 2)].into_iter().collect(), - /// sale_dm: vec![(TradeClass::Industrial, 3)].into_iter().collect(), - /// }, 5).unwrap(); + /// // Add a good to the market for a pop 5 world + /// market.add_entry(TradeTable::global().get(14).unwrap().clone(), 5).unwrap(); /// // Skilled buyer (3) vs average seller (1) on agricultural world /// market.price_goods_to_buy(&[TradeClass::Agricultural], 3, 1); - /// // Expect better prices due to +2 skill differential + /// // Expect better prices due to +2 skill differential and +3 Ag DM /// ``` pub fn price_goods_to_buy( &mut self, @@ -461,9 +460,16 @@ impl AvailableGoodsTable { // Roll 2d6 let roll = rng.random_range(1..=6) + rng.random_range(1..=6) + rng.random_range(1..=6); - let purchase_origin_dm = - find_total_dm(&good.source_entry.purchase_dm, origin_trade_classes); - let sale_origin_dm = find_total_dm(&good.source_entry.sale_dm, origin_trade_classes); + let entry = TradeTable::global() + .get(good.source_index) + .unwrap_or_else(|| { + panic!( + "Failed to get trade table entry for index {}", + &good.source_index + ) + }); + let purchase_origin_dm = find_max_dm(&entry.purchase_dm, origin_trade_classes); + let sale_origin_dm = find_max_dm(&entry.sale_dm, origin_trade_classes); // Calculate the modified roll let modified_roll = roll as i16 + buyer_broker_skill - supplier_broker_skill + purchase_origin_dm @@ -634,8 +640,17 @@ impl AvailableGood { // Roll 3d6 let roll = rng.random_range(1..=6) + rng.random_range(1..=6) + rng.random_range(1..=6); - let purchase_origin_dm = find_total_dm(&self.source_entry.purchase_dm, destination_trade_classes); - let sale_origin_dm = find_total_dm(&self.source_entry.sale_dm, destination_trade_classes); + let entry = TradeTable::global() + .get(self.source_index) + .unwrap_or_else(|| { + panic!( + "Failed to get trade table entry for index {}", + &self.source_index + ) + }); + + let purchase_origin_dm = find_max_dm(&entry.purchase_dm, destination_trade_classes); + let sale_origin_dm = find_max_dm(&entry.sale_dm, destination_trade_classes); // Calculate the modified roll (mirror price_goods_to_sell) let modified_roll = roll as i16 - buyer_broker_skill + supplier_broker_skill @@ -692,18 +707,11 @@ impl AvailableGood { } } - -/// Calculate total trade DMs for a set of world trade classes +/// Calculate max DM for a set of world trade classes /// -/// Sums all applicable Difficulty Modifiers (DMs) from a trade good's DM map -/// that match the world's trade classifications. This determines the total -/// bonus or penalty applied to trade rolls for this good at this world. -/// -/// ## DM Accumulation -/// -/// Unlike some systems that take the best single DM, this function sums -/// all applicable DMs, allowing worlds with multiple relevant trade classes -/// to receive cumulative bonuses. +/// Find all relevant DMs given the world trade classes adn the map +/// of DMs for this world to trade classes. Return the max DM or 0 +/// if there are no applicable DMs. /// /// ## Parameters /// @@ -718,32 +726,29 @@ impl AvailableGood { /// /// ```rust,ignore /// // Agricultural world (+2) that's also Rich (+1) for electronics -/// let total_dm = find_total_dm(&electronics_purchase_dm, &[Agricultural, Rich]); -/// // Returns: 3 (2 + 1) +/// let max_dm = find_max_dm(&electronics_purchase_dm, &[Agricultural, Rich]); +/// // Returns: 2 max(2, 1) /// /// // Industrial world with no applicable DMs for agricultural products -/// let total_dm = find_total_dm(&ag_products_purchase_dm, &[Industrial]); +/// let max_dm = find_max_dm(&ag_products_purchase_dm, &[Industrial]); /// // Returns: 0 /// ``` -fn find_total_dm( +fn find_max_dm( dm_map: &std::collections::HashMap, world_trade_classes: &[TradeClass], ) -> i16 { - let mut total_dm: i16 = 0; - - for trade_class in world_trade_classes { - if let Some(&dm) = dm_map.get(trade_class) { - total_dm += dm; - } - } + let eligible_dms: Vec = world_trade_classes + .iter() + .filter_map(|tc| dm_map.get(tc)) + .cloned() + .collect(); - total_dm + eligible_dms.into_iter().max().unwrap_or(0) } #[cfg(test)] mod tests { use super::*; - use crate::trade::table::{Availability, Quantity}; use crate::trade::TradeClass; use rand::SeedableRng; @@ -798,14 +803,26 @@ mod tests { // Common Electronics (11) has purchase DM for Rich+1 let electronics = available_goods.get_by_index(11).unwrap(); assert_eq!( - find_total_dm(&electronics.source_entry.purchase_dm, &world_trade_classes), + find_max_dm( + &TradeTable::global() + .get(electronics.source_index) + .unwrap() + .purchase_dm, + &world_trade_classes + ), 1 ); // Agricultural Products (33) has purchase DM for Agricultural+2 let ag_products = available_goods.get_by_index(33).unwrap(); assert_eq!( - find_total_dm(&ag_products.source_entry.purchase_dm, &world_trade_classes), + find_max_dm( + &TradeTable::global() + .get(ag_products.source_index) + .unwrap() + .purchase_dm, + &world_trade_classes + ), 2 ); @@ -834,48 +851,30 @@ mod tests { let world_trade_classes = vec![TradeClass::Agricultural, TradeClass::Rich]; // Rich should be the best DM - let total_dm = find_total_dm(&dm_map, &world_trade_classes); - assert_eq!(total_dm, 8); + let total_dm = find_max_dm(&dm_map, &world_trade_classes); + assert_eq!(total_dm, 5); // World with only Agricultural trade class let world_trade_classes = vec![TradeClass::Agricultural]; // Agricultural should be the best (and only) DM - let best_dm = find_total_dm(&dm_map, &world_trade_classes); + let best_dm = find_max_dm(&dm_map, &world_trade_classes); assert_eq!(best_dm, 3); // World with no matching trade classes let world_trade_classes = vec![TradeClass::Industrial, TradeClass::Poor]; // No DM should be found - let best_dm = find_total_dm(&dm_map, &world_trade_classes); + let best_dm = find_max_dm(&dm_map, &world_trade_classes); assert_eq!(best_dm, 0); } #[test_log::test] fn test_price_goods() { - // Create a simple trade table entry for testing - let mut purchase_dm = HashMap::new(); - purchase_dm.insert(TradeClass::Rich, 2); - - let mut sale_dm = HashMap::new(); - sale_dm.insert(TradeClass::Agricultural, 3); - - let entry = TradeTableEntry { - index: 1, - name: "Test Good".to_string(), - availability: Availability::All, - quantity: Quantity { - dice: 2, - multiplier: 1, - }, - base_cost: 10000, - purchase_dm, - sale_dm, - }; + let entry = TradeTable::global().get(11).unwrap().clone(); // Create a world with both trade classes - let world_trade_classes = vec![TradeClass::Rich, TradeClass::Agricultural]; + let world_trade_classes = vec![TradeClass::Rich, TradeClass::Industrial]; // Create a table with a single good let mut table = AvailableGoodsTable::new(); @@ -921,7 +920,7 @@ mod tests { fn test_display() { // Create a simple good let good = AvailableGood { - name: "Test Good".to_string(), + name: "Common Electronics".to_string(), quantity: 10, base_cost: 5000, buy_cost: 5000, @@ -929,29 +928,21 @@ mod tests { purchased: 0, sell_price_comment: String::default(), sell_price: None, - source_entry: TradeTableEntry { - index: 1, - name: "Test Good".to_string(), - availability: Availability::All, - quantity: Quantity { - dice: 2, - multiplier: 1, - }, - base_cost: 5000, - purchase_dm: HashMap::new(), - sale_dm: HashMap::new(), - }, + source_index: 11, }; // Check the display output - assert_eq!(format!("{good}"), "Test Good: 10 @ 5000 (100% of base)"); + assert_eq!( + format!("{good}"), + "Common Electronics: 10 @ 5000 (100% of base)" + ); // Create a table with a single good let mut table = AvailableGoodsTable::new(); table.goods.push(good); // Check the display output - let expected = "Test Good: 10 @ 5000 (100% of base)\n"; + let expected = "Common Electronics: 10 @ 5000 (100% of base)\n"; assert_eq!(format!("{table}"), expected); // Create an empty table @@ -998,7 +989,7 @@ mod tests { // Add goods with different discounts let good1 = AvailableGood { - name: "Good 1".to_string(), + name: "Common Electronics".to_string(), quantity: 10, base_cost: 10000, buy_cost: 5000, // 50% of base @@ -1006,22 +997,11 @@ mod tests { purchased: 0, sell_price_comment: String::default(), sell_price: None, - source_entry: TradeTableEntry { - index: 1, - name: "Good 1".to_string(), - availability: Availability::All, - quantity: Quantity { - dice: 2, - multiplier: 1, - }, - base_cost: 10000, - purchase_dm: HashMap::new(), - sale_dm: HashMap::new(), - }, + source_index: 11, }; let good2 = AvailableGood { - name: "Good 2".to_string(), + name: "Common Industrial Goods".to_string(), quantity: 10, base_cost: 10000, buy_cost: 8000, // 80% of base @@ -1029,22 +1009,11 @@ mod tests { purchased: 0, sell_price_comment: String::default(), sell_price: None, - source_entry: TradeTableEntry { - index: 2, - name: "Good 2".to_string(), - availability: Availability::All, - quantity: Quantity { - dice: 2, - multiplier: 1, - }, - base_cost: 10000, - purchase_dm: HashMap::new(), - sale_dm: HashMap::new(), - }, + source_index: 12, }; let good3 = AvailableGood { - name: "Good 3".to_string(), + name: "Common Manufactured Goods".to_string(), quantity: 10, base_cost: 10000, buy_cost: 2000, // 20% of base @@ -1052,18 +1021,7 @@ mod tests { purchased: 0, sell_price_comment: String::default(), sell_price: None, - source_entry: TradeTableEntry { - index: 3, - name: "Good 3".to_string(), - availability: Availability::All, - quantity: Quantity { - dice: 2, - multiplier: 1, - }, - base_cost: 10000, - purchase_dm: HashMap::new(), - sale_dm: HashMap::new(), - }, + source_index: 13, }; // Add goods in random order @@ -1075,9 +1033,9 @@ mod tests { table.sort_by_discount(); // Check that goods are sorted from most discounted to least discounted - assert_eq!(table.goods[0].name, "Good 3"); // 20% of base - assert_eq!(table.goods[1].name, "Good 1"); // 50% of base - assert_eq!(table.goods[2].name, "Good 2"); // 80% of base + assert_eq!(table.goods[0].name, "Common Manufactured Goods"); // 20% of base + assert_eq!(table.goods[1].name, "Common Electronics"); // 50% of base + assert_eq!(table.goods[2].name, "Common Industrial Goods"); // 80% of base // Print the sorted table println!("Sorted table:\n{table}"); @@ -1092,25 +1050,14 @@ mod tests { let mut sale_dm = HashMap::new(); sale_dm.insert(TradeClass::Agricultural, 3); - let entry = TradeTableEntry { - index: 1, - name: "Test Good".to_string(), - availability: Availability::All, - quantity: Quantity { - dice: 2, - multiplier: 1, - }, - base_cost: 10000, - purchase_dm, - sale_dm, - }; + let entry = TradeTable::global().get(11).unwrap().clone(); // Create a table with a single good let mut table = AvailableGoodsTable::new(); table.add_entry(entry.clone(), 5).unwrap(); // Test with destination trade classes - let destination_trade_classes = vec![TradeClass::Rich, TradeClass::Agricultural]; + let destination_trade_classes = vec![TradeClass::Rich, TradeClass::HighTech]; // Price the goods for sale let mut rng = rand::rngs::StdRng::seed_from_u64(12345); diff --git a/src/trade/available_passengers.rs b/src/trade/available_passengers.rs index 8037e54..a12f9fd 100644 --- a/src/trade/available_passengers.rs +++ b/src/trade/available_passengers.rs @@ -2,19 +2,22 @@ //! //! This module handles the generation of available passengers and freight //! for interstellar travel between worlds in the Traveller universe. - use crate::trade::{PortCode, ZoneClassification}; use crate::util::{roll_1d6, roll_2d6}; +use serde::{Deserialize, Serialize}; + +#[allow(unused_imports)] +use log::debug; /// Represents a lot of freight available for shipping at a specific world -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct FreightLot { /// Size in tons (1-60) pub size: i32, } /// Represents available passengers (by class of passage) and freight for a route between two worlds -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct AvailablePassengers { /// Number of high passage passengers pub high: i32, @@ -59,6 +62,7 @@ impl AvailablePassengers { destination_tech_level: i32, distance_parsecs: i32, steward_skill: i32, + player_broker_skill: i32, ) -> Self { let mut passengers = Self::default(); @@ -87,6 +91,7 @@ impl AvailablePassengers { destination_zone, destination_tech_level, distance_parsecs, + player_broker_skill, ); passengers @@ -192,6 +197,7 @@ impl AvailablePassengers { destination_zone: ZoneClassification, destination_tech_level: i32, distance_parsecs: i32, + player_broker_skill: i32, ) { // Generate each cargo class for cargo_class in [CargoClass::Major, CargoClass::Minor, CargoClass::Incidental] { @@ -205,6 +211,7 @@ impl AvailablePassengers { destination_zone, destination_tech_level, distance_parsecs, + player_broker_skill, cargo_class, ); @@ -252,11 +259,16 @@ impl AvailablePassengers { destination_zone: ZoneClassification, destination_tech_level: i32, distance_parsecs: i32, + player_broker_skill: i32, cargo_class: CargoClass, ) -> i32 { // Initial 2d6 roll let mut roll = roll_2d6(); + // Modify by effect of player broker skill check + let check = player_broker_skill + roll_2d6() - 8; + roll += check; + // Cargo class modifiers match cargo_class { CargoClass::Major => roll -= 4, @@ -265,16 +277,10 @@ impl AvailablePassengers { } // Population modifiers - if origin_population <= 1 { - roll -= 4; - } - if destination_population <= 1 { - roll -= 4; - } - - // Population bonuses for pop in [origin_population, destination_population] { - if pop >= 8 { + if pop <= 1 { + roll -= 4; + } else if pop >= 8 { roll += 4; } else if pop >= 6 { roll += 2; @@ -315,28 +321,7 @@ impl AvailablePassengers { roll -= distance_parsecs - 1; } - // Determine number of dice to roll based on modified result - let dice_count = match roll { - i32::MIN..=1 => return 0, - 2..=3 => 1, - 4..=6 => 2, - 7..=10 => 3, - 11..=13 => 4, - 14..=15 => 5, - 16 => 6, - 17 => 7, - 18 => 8, - 19 => 9, - 20..=i32::MAX => 10, - }; - - // Roll the determined number of d6 - let mut total = 0; - for _ in 0..dice_count { - total += roll_1d6(); - } - - total + roll } /// Generates the number of passengers for a specific passenger class diff --git a/src/trade/mod.rs b/src/trade/mod.rs index 88fede8..bee4e7f 100644 --- a/src/trade/mod.rs +++ b/src/trade/mod.rs @@ -4,8 +4,8 @@ //! including trade classifications, starport codes, zone classifications, and //! utilities for generating trade data from Universal World Profiles (UWPs). +use serde::{Deserialize, Serialize}; use std::fmt::Display; - pub mod available_goods; pub mod available_passengers; pub mod ship_manifest; @@ -16,7 +16,7 @@ pub mod table; /// These classifications affect trade good availability, pricing modifiers, /// and economic relationships between worlds. A world can have multiple /// trade classifications based on its physical and social characteristics. -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] pub enum TradeClass { /// Agricultural world - produces food and organic materials /// @@ -353,7 +353,7 @@ pub fn upp_to_trade_classes(upp: &[char]) -> Vec { /// /// Represents the quality and capabilities of a world's starport facilities, /// affecting trade, refueling, maintenance, and shipyard services. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum PortCode { /// Excellent starport - Full shipyard, refined fuel, all services A, @@ -366,6 +366,7 @@ pub enum PortCode { /// Frontier starport - No repairs, no fuel, landing area only E, /// No starport - No facilities, dangerous landing + #[default] X, /// No starport - No facilities, no landing possible Y, @@ -421,8 +422,9 @@ impl std::fmt::Display for PortCode { /// /// Indicates the safety level and travel restrictions for a world, /// affecting passenger traffic, trade, and insurance rates. -#[derive(Debug, Clone, PartialEq, Copy)] +#[derive(Debug, Clone, PartialEq, Copy, Eq, Serialize, Deserialize, Default)] pub enum ZoneClassification { + #[default] /// Green zone - Safe for travel, no restrictions Green, /// Amber zone - Caution advised, potential dangers @@ -440,3 +442,13 @@ impl std::fmt::Display for ZoneClassification { } } } + +impl From<&str> for ZoneClassification { + fn from(s: &str) -> Self { + match s { + "Amber" => ZoneClassification::Amber, + "Red" => ZoneClassification::Red, + _ => ZoneClassification::Green, + } + } +} diff --git a/src/trade/ship_manifest.rs b/src/trade/ship_manifest.rs index 3de6e94..0d528aa 100644 --- a/src/trade/ship_manifest.rs +++ b/src/trade/ship_manifest.rs @@ -5,17 +5,19 @@ //! //! The manifest tracks different classes of passengers, freight lots, and trade goods, //! and calculates revenue based on distance traveled and passenger/freight types. +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use crate::trade::available_goods::AvailableGood; -use crate::trade::available_goods::AvailableGoodsTable; -use std::collections::HashMap; +use crate::trade::available_passengers::AvailablePassengers; +use crate::trade::table::TradeTable; /// Represents a ship's manifest of passengers, freight, and trade goods /// /// Tracks the number of passengers in each class, the indices of /// freight lots being carried, and speculative trade goods purchased. /// Used to calculate total revenue for a trading voyage between worlds. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ShipManifest { /// Number of high passage passengers (luxury accommodations) pub high_passengers: i32, @@ -31,6 +33,8 @@ pub struct ShipManifest { pub trade_goods: Vec, /// Planned sell amounts for goods (keyed by source_entry.index) pub sell_plan: HashMap, + /// Accumulated profit across processed trades (in credits) + pub profit: i64, } /// Revenue (in credits) per high passage passenger by distance (in parsecs) @@ -52,13 +56,30 @@ const BASIC_COST: [i32; 7] = [0, 2000, 3000, 5000, 8000, 14000, 55000]; /// /// Index 0 is unused, indices 1-6 represent jump distances 1-6 const LOW_COST: [i32; 7] = [0, 700, 1300, 2200, 3900, 7200, 27000]; - -/// Revenue per freight lot by distance (in parsecs) /// /// Index 0 is unused, indices 1-6 represent jump distances 1-6 const FREIGHT_COST: [i32; 7] = [0, 1000, 1600, 2600, 4400, 8500, 32000]; impl ShipManifest { + /// Returns the total number of passengers in the manifest + pub fn total_passengers_not_low(&self) -> i32 { + self.high_passengers + self.medium_passengers + self.basic_passengers + } + + /// Returns the total tonnage of freight in the manifest, given available_passengers + pub fn total_freight_tons(&self, available_passengers: &AvailablePassengers) -> i32 { + self.freight_lot_indices + .iter() + .map(|&index| { + available_passengers + .freight_lots + .get(index) + .map(|lot| lot.size) + .unwrap_or(0) + }) + .sum() + } + /// Calculates total passenger revenue for the manifest /// /// Computes revenue based on the number of passengers in each class @@ -162,7 +183,7 @@ impl ShipManifest { /// buy_cost_comment: String::new(), /// sell_price: None, /// sell_price_comment: String::new(), - /// source_entry: entry, + /// source_index: entry.index, /// }; /// /// // Add a good with quantity 5 @@ -173,12 +194,12 @@ impl ShipManifest { /// manifest.update_trade_good(&good, 0); /// ``` pub fn update_trade_good(&mut self, good: &AvailableGood, quantity: i32) { - let index = good.source_entry.index; + let index = good.source_index; // Find existing good by source entry index if let Some(pos) = self .trade_goods .iter() - .position(|g| g.source_entry.index == index) + .position(|g| g.source_index == index) { if quantity <= 0 { // Remove the good if quantity is 0 or negative @@ -208,10 +229,10 @@ impl ShipManifest { if let Some(existing) = self .trade_goods .iter() - .find(|g| g.source_entry.index == good.source_entry.index) + .find(|g| g.source_index == good.source_index) { let clamped = amount.clamp(0, existing.purchased); - self.sell_plan.insert(good.source_entry.index, clamped); + self.sell_plan.insert(good.source_index, clamped); } } @@ -220,11 +241,11 @@ impl ShipManifest { let purchased = self .trade_goods .iter() - .find(|g| g.source_entry.index == good.source_entry.index) + .find(|g| g.source_index == good.source_index) .map(|g| g.purchased) .unwrap_or(0); self.sell_plan - .get(&good.source_entry.index) + .get(&good.source_index) .copied() .unwrap_or(0) .min(purchased) @@ -236,27 +257,30 @@ impl ShipManifest { /// Returns the quantity of the specified good currently in the manifest, /// or 0 if the good is not in the manifest. pub fn get_trade_good_quantity(&self, good: &AvailableGood) -> i32 { - self.get_trade_good_quantity_by_index(good.source_entry.index) + self.get_trade_good_quantity_by_index(good.source_index) } /// Gets the quantity of a specific trade good by its trade table index pub fn get_trade_good_quantity_by_index(&self, index: i16) -> i32 { self.trade_goods .iter() - .find(|g| g.source_entry.index == index) + .find(|g| g.source_index == index) .map(|g| g.purchased) .unwrap_or(0) } /// Removes a trade good from the manifest by index, if present pub fn remove_trade_good_by_index(&mut self, index: i16) { - if let Some(pos) = self.trade_goods.iter().position(|g| g.source_entry.index == index) { + if let Some(pos) = self + .trade_goods + .iter() + .position(|g| g.source_index == index) + { self.trade_goods.remove(pos); } self.sell_plan.remove(&index); } - /// Commits the planned sell amount for the given good index: /// subtracts the planned amount from the manifest (down to 0), /// removes the good if it reaches 0, and resets the sell plan to 0. @@ -265,7 +289,7 @@ impl ShipManifest { if let Some(pos) = self .trade_goods .iter() - .position(|g| g.source_entry.index == index) + .position(|g| g.source_index == index) { let sell_amt = self.get_sell_amount_by_index(index); if sell_amt <= 0 { @@ -289,12 +313,10 @@ impl ShipManifest { } } - - /// Commits all planned sales across all goods in the manifest pub fn commit_all_sales(&mut self) { // Collect indices first to avoid borrow issues while mutating - let indices: Vec = self.trade_goods.iter().map(|g| g.source_entry.index).collect(); + let indices: Vec = self.trade_goods.iter().map(|g| g.source_index).collect(); for idx in indices { self.commit_sale_by_index(idx); } @@ -325,6 +347,13 @@ impl ShipManifest { .sum() } + /// Zeros out the buy costs of all trade goods in the manifest + pub fn zero_buy_costs(&mut self) { + for good in self.trade_goods.iter_mut() { + good.buy_cost = 0; + } + } + /// Calculates the total potential proceeds from trade goods in the manifest /// /// Returns the total potential selling value of all trade goods currently @@ -340,7 +369,7 @@ impl ShipManifest { if let Some(sell_price) = g.sell_price { let to_sell = self .sell_plan - .get(&g.source_entry.index) + .get(&g.source_index) .copied() .unwrap_or(0) .min(g.purchased) @@ -371,5 +400,96 @@ impl ShipManifest { self.basic_passengers = 0; self.low_passengers = 0; self.freight_lot_indices.clear(); + self.sell_plan.clear(); + } + + /// Process trades: add current Total to profit and clear passenger/freight counts and sell plans + /// Does NOT clear trade_goods quantities (tons) or list; only resets sell_plan to 0 and passenger/freight + pub fn process_trades(&mut self, distance: i32, show_sell: bool) { + // Compute current totals + let passenger_revenue = self.passenger_revenue(distance) as i64; + let freight_revenue = self.freight_revenue(distance) as i64; + let goods_profit = if show_sell { + let cost = self.trade_goods_cost(); + let proceeds = self.trade_goods_proceeds(); + proceeds - cost + } else { + 0 + }; + + // Subtract the value in each sell plan from the amount of goods in the manifest + // Remove from trade goods if less than 0. + for (index, amount) in self.sell_plan.iter() { + if let Some(pos) = self + .trade_goods + .iter() + .position(|g| g.source_index == *index) + { + let new_qty = self.trade_goods[pos].purchased - amount; + self.trade_goods[pos].purchased = new_qty.max(0); + if new_qty <= 0 { + self.trade_goods.remove(pos); + } + } + } + + // Clear the sell plan. + self.sell_plan.clear(); + + // Compute total revenue + let total = passenger_revenue + freight_revenue + goods_profit; + // Add to accumulated profit + self.profit += total; + + // Reset passengers, freight, and drop the cost expended for future trades. + self.reset_passengers_and_freight(); + self.zero_buy_costs(); + } + + pub fn manifest_goods_list(&self) -> Vec { + let manifest_goods: Vec = self + .trade_goods + .clone() + .iter_mut() + .map(|g| { + g.quantity = 0; + g.purchased = 0; + g.buy_cost = g.base_cost; + g.buy_cost_comment.clear(); + g.sell_price = None; + g.sell_price_comment.clear(); + g.clone() + }) + .collect(); + + // These are goods where entered a plan to sell all we have in our manifest. + // Thus they are missing from the trade_goods itself. + let planned_goods: Vec = self + .sell_plan + .iter() + .filter_map(|(idx, amt)| { + if *amt > 0 && !manifest_goods.iter().any(|g| g.source_index == *idx) { + TradeTable::default().get(*idx).map(|entry| { + let mut g = AvailableGood { + name: entry.name.clone(), + quantity: 0, + purchased: 0, + base_cost: entry.base_cost, + buy_cost: entry.base_cost, + buy_cost_comment: String::new(), + sell_price: None, + sell_price_comment: String::new(), + source_index: entry.index, + }; + g.purchased = *amt; + g + }) + } else { + None + } + }) + .collect(); + + manifest_goods.into_iter().chain(planned_goods).collect() } } diff --git a/src/trade/table.rs b/src/trade/table.rs index bd2df1c..2096a35 100644 --- a/src/trade/table.rs +++ b/src/trade/table.rs @@ -7,10 +7,19 @@ //! restrictions, quantity dice, and trade class modifiers for buying and selling. use super::TradeClass; +use lazy_static::lazy_static; use std::collections::HashMap; use crate::trade::string_to_trade_class; +lazy_static! { + /// Global trade table instance initialized once with standard trade goods + /// + /// Contains all 36 standard trade goods from the Traveller rules. + /// Initialized once on first access and reused throughout the application. + static ref TRADE_TABLE: TradeTable = TradeTable::default(); +} + /// Main trade table containing all available trade goods /// /// Maps two-digit indices (11-66) to trade table entries. The indices correspond @@ -21,6 +30,15 @@ pub struct TradeTable { entries: HashMap, } +impl TradeTable { + /// Get reference to the global trade table + /// + /// Returns a reference to the lazily-initialized global trade table + /// containing all standard trade goods. + pub fn global() -> &'static TradeTable { + &TRADE_TABLE + } +} /// Individual entry in the trade table representing a specific trade good /// /// Contains all information needed to determine availability, quantity, diff --git a/src/util.rs b/src/util.rs index dba27ad..a6376cf 100644 --- a/src/util.rs +++ b/src/util.rs @@ -4,7 +4,7 @@ //! including random number generation for dice rolls and number base conversion utilities. pub use rand::Rng; - +use std::fmt::Display; /// Converts Arabic numerals to Roman numerals for numbers 0-20 /// /// Used primarily for displaying orbital positions and other small numbers @@ -67,6 +67,46 @@ pub fn arabic_to_roman(num: usize) -> String { "".to_string() } +/// Utility type to easily format and convert things from credits into MCr +/// +/// Supports conversion from i64, i32, i16, and f64 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Mcr(i64); + +impl Display for Mcr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:.2}", self.0 as f64 / 1_000_000.0) + } +} +impl From for Mcr { + fn from(credits: i64) -> Self { + Mcr(credits) + } +} + +impl From for Mcr { + fn from(credits: i32) -> Self { + Mcr(credits as i64) + } +} + +impl From for Mcr { + fn from(credits: i16) -> Self { + Mcr(credits as i64) + } +} + +impl From for Mcr { + fn from(credits: f64) -> Self { + Mcr((credits * 1_000_000.0) as i64) + } +} + +/// Convert a i16 for Credits into MCr +pub fn mcr(credits: i64) -> f64 { + credits as f64 / 1_000_000.0 +} + /// Simulates rolling two six-sided dice (2d6) /// /// This is the most common dice roll in Traveller, used for everything from diff --git a/style.css b/style.css index 7b521d4..4ed1e4c 100644 --- a/style.css +++ b/style.css @@ -1,75 +1,93 @@ /* latin */ @font-face { - font-family: 'Orbitron'; + font-family: "Orbitron"; font-style: normal; font-weight: 400 900; font-display: swap; - src: url(https://fonts.gstatic.com/s/orbitron/v31/yMJRMIlzdpvBhQQL_Qq7dy1biN15.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url(https://fonts.gstatic.com/s/orbitron/v31/yMJRMIlzdpvBhQQL_Qq7dy1biN15.woff2) + format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* vietnamese */ @font-face { - font-family: 'Sono'; + font-family: "Sono"; font-style: normal; font-weight: 200 800; font-display: swap; - src: url(https://fonts.gstatic.com/s/sono/v6/aFTO7PNiY3U2Cqf_aYEN64CYaK18YUhHma9UZrHlF80.woff2) format('woff2'); - unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; + src: url(https://fonts.gstatic.com/s/sono/v6/aFTO7PNiY3U2Cqf_aYEN64CYaK18YUhHma9UZrHlF80.woff2) + format("woff2"); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, + U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { - font-family: 'Sono'; + font-family: "Sono"; font-style: normal; font-weight: 200 800; font-display: swap; - src: url(https://fonts.gstatic.com/s/sono/v6/aFTO7PNiY3U2Cqf_aYEN64CYaK18YUhGma9UZrHlF80.woff2) format('woff2'); - unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + src: url(https://fonts.gstatic.com/s/sono/v6/aFTO7PNiY3U2Cqf_aYEN64CYaK18YUhGma9UZrHlF80.woff2) + format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, + U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, + U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { - font-family: 'Sono'; + font-family: "Sono"; font-style: normal; font-weight: 200 800; font-display: swap; - src: url(https://fonts.gstatic.com/s/sono/v6/aFTO7PNiY3U2Cqf_aYEN64CYaK18YUhIma9UZrHl.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url(https://fonts.gstatic.com/s/sono/v6/aFTO7PNiY3U2Cqf_aYEN64CYaK18YUhIma9UZrHl.woff2) + format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* latin */ @font-face { - font-family: 'Space Mono'; + font-family: "Space Mono"; font-style: normal; font-weight: 400; font-display: swap; - src: url(https://fonts.gstatic.com/s/spacemono/v14/i7dPIFZifjKcF5UAWdDRYEF8RXi4EwQ.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url(https://fonts.gstatic.com/s/spacemono/v14/i7dPIFZifjKcF5UAWdDRYEF8RXi4EwQ.woff2) + format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* vietnamese */ @font-face { - font-family: 'Space Mono'; + font-family: "Space Mono"; font-style: normal; font-weight: 700; font-display: swap; - src: url(https://fonts.gstatic.com/s/spacemono/v14/i7dMIFZifjKcF5UAWdDRaPpZUFqaHi6WZ3S_Yg.woff2) format('woff2'); - unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; + src: url(https://fonts.gstatic.com/s/spacemono/v14/i7dMIFZifjKcF5UAWdDRaPpZUFqaHi6WZ3S_Yg.woff2) + format("woff2"); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, + U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { - font-family: 'Space Mono'; + font-family: "Space Mono"; font-style: normal; font-weight: 700; font-display: swap; - src: url(https://fonts.gstatic.com/s/spacemono/v14/i7dMIFZifjKcF5UAWdDRaPpZUFuaHi6WZ3S_Yg.woff2) format('woff2'); - unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; + src: url(https://fonts.gstatic.com/s/spacemono/v14/i7dMIFZifjKcF5UAWdDRaPpZUFuaHi6WZ3S_Yg.woff2) + format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, + U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, + U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { - font-family: 'Space Mono'; + font-family: "Space Mono"; font-style: normal; font-weight: 700; font-display: swap; - src: url(https://fonts.gstatic.com/s/spacemono/v14/i7dMIFZifjKcF5UAWdDRaPpZUFWaHi6WZ3Q.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url(https://fonts.gstatic.com/s/spacemono/v14/i7dMIFZifjKcF5UAWdDRaPpZUFWaHi6WZ3Q.woff2) + format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } * { @@ -228,7 +246,7 @@ input[type="text"] { font-size: 12pt; } - #entry-buttons input { +#entry-buttons input { flex: 1; margin: 0 5px; } @@ -244,26 +262,27 @@ input[type="text"] { .output-region { border: 1px solid #ccc; border-radius: 5px; - padding: 10px; + padding: 1rem; + margin-bottom: 1rem; + background: rgba(0, 0, 0, 0.3); overflow: auto; width: fit-content; +} - } - - .table-entry{ +.table-entry { padding-left: 0em; color: white; - } +} - .blue-button { - background-image: linear-gradient(#42A1EC, #0070C9); - border: 1px solid #0077CC; +.blue-button { + background-image: linear-gradient(#42a1ec, #0070c9); + border: 1px solid #0077cc; border-radius: 4px; box-sizing: border-box; - color: #FFFFFF; + color: #ffffff; cursor: pointer; direction: ltr; - letter-spacing: -.022em; + letter-spacing: -0.022em; line-height: 1.47059; overflow: visible; margin: 2px; @@ -283,18 +302,18 @@ input[type="text"] { .blue-button:disabled { cursor: default; - opacity: .3; + opacity: 0.3; } .blue-button:hover { - background-image: linear-gradient(#51A9EE, #147BCD); - border-color: #1482D0; + background-image: linear-gradient(#51a9ee, #147bcd); + border-color: #1482d0; text-decoration: none; } .blue-button:active { - background-image: linear-gradient(#3D94D9, #0067B9); - border-color: #006DBC; + background-image: linear-gradient(#3d94d9, #0067b9); + border-color: #006dbc; outline: none; } @@ -304,9 +323,9 @@ input[type="text"] { } .loading-indicator { - margin-left: 10px; - font-size: 0.8em; - color: #666; + margin-left: 10px; + font-size: 0.8em; + color: #666; } .world-suggestions { @@ -339,89 +358,89 @@ input[type="text"] { } .passengers-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; - margin-bottom: 1rem; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 1rem; } .passenger-type { - text-align: center; - border: 1px solid #ccc !important; - border-radius: 5px; - padding: 0.5rem; - background: none; - color: white; - cursor: pointer; - font-family: inherit; - display: flex; - flex-direction: column; - align-items: center; - outline: none; - box-shadow: none; + text-align: center; + border: 1px solid #ccc !important; + border-radius: 5px; + padding: 0.5rem; + background: none; + color: white; + cursor: pointer; + font-family: inherit; + display: flex; + flex-direction: column; + align-items: center; + outline: none; + box-shadow: none; } .passenger-type:focus { - outline: none; - border: 1px solid #ccc !important; + outline: none; + border: 1px solid #ccc !important; } .passenger-type:active { - background-image: linear-gradient(#3D94D9, #0067B9); - border: 1px solid #006DBC !important; + background-image: linear-gradient(#3d94d9, #0067b9); + border: 1px solid #006dbc !important; } .passenger-type h4 { - font-size: 10pt; - margin: 0; + font-size: 10pt; + margin: 0; } .passenger-count { - font-size: 12pt; - font-weight: bold; - margin-top: 0.5rem; + font-size: 12pt; + font-weight: bold; + margin-top: 0.5rem; } .passenger-button { - background: none; - border: none; - color: white; - font-size: 12pt; - font-weight: bold; - margin-top: 0.5rem; - cursor: pointer; - padding: 0; - font-family: inherit; + background: none; + border: none; + color: white; + font-size: 12pt; + font-weight: bold; + margin-top: 0.5rem; + cursor: pointer; + padding: 0; + font-family: inherit; } .passenger-button:active { - background-image: linear-gradient(#3D94D9, #0067B9); - border: 1px solid #006DBC; - border-radius: 4px; - padding: 2px 4px; + background-image: linear-gradient(#3d94d9, #0067b9); + border: 1px solid #006dbc; + border-radius: 4px; + padding: 2px 4px; } .freight-grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 0.5rem; - margin-bottom: 1rem; + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.5rem; + margin-bottom: 1rem; } .freight-lot { - text-align: center; - border: 1px solid #ccc; - border-radius: 3px; - padding: 0.25rem; - background-color: rgba(255, 255, 255, 0.1); - color: white; - cursor: pointer; - font-family: inherit; + text-align: center; + border: 1px solid #ccc; + border-radius: 3px; + padding: 0.25rem; + background-color: rgba(255, 255, 255, 0.1); + color: white; + cursor: pointer; + font-family: inherit; } .freight-selected { - background-image: linear-gradient(#3D94D9, #0067B9); - border: 1px solid #006DBC; + background-image: linear-gradient(#3d94d9, #0067b9); + border: 1px solid #006dbc; } .sell-price-button { @@ -435,17 +454,7 @@ input[type="text"] { cursor: pointer; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); transition: all 0.2s ease; -} - -.sell-price-button:hover { - background: linear-gradient(135deg, #7a7a7a 0%, #5a5a5a 100%); - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); - transform: translateY(-1px); -} - -.sell-price-button:active { - transform: translateY(0); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + margin: 0 0.5rem 0 0.15rem; } .selector-container { @@ -481,122 +490,176 @@ input[type="text"] { } .tool-card p { - line-height: 1.5; - margin-bottom: 1.5rem; - flex-grow: 1; - text-align: left; + line-height: 1.5; + margin-bottom: 1.5rem; + flex-grow: 1; + text-align: left; } .tool-card button { - align-self: center; + align-self: center; } .manifest-container { - border: 1px solid #ccc; - border-radius: 5px; - padding: 1rem; - margin-bottom: 1rem; - background: rgba(0, 0, 0, 0.3); - width: fit-content; + border: 1px solid #ccc; + border-radius: 5px; + padding: 1rem; + margin-bottom: 1rem; + background: rgba(0, 0, 0, 0.3); + width: fit-content; } .manifest-section { - margin-bottom: 1rem; + margin-bottom: 1rem; } .manifest-section h5 { - margin: 0 0 0.5rem 0; - color: #51A9EE; - font-size: 12pt; + margin: 0 0 0.5rem 0; + color: #51a9ee; + font-size: 12pt; } .manifest-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.5rem; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; } .manifest-item { - display: flex; - justify-content: space-between; - padding: 0.25rem 0.5rem; - border: 1px solid #555; - border-radius: 3px; - background: rgba(255, 255, 255, 0.05); + display: flex; + justify-content: space-between; + padding: 0.25rem 0.5rem; + border: 1px solid #555; + border-radius: 3px; + background: rgba(255, 255, 255, 0.05); } .manifest-label { - font-weight: normal; + font-weight: normal; + margin-top: 0px; +} + +.manifest-first-row { + line-height: 1; + } .manifest-value { - font-weight: bold; - color: #51A9EE; + font-weight: bold; + color: #51a9ee; +} + +.manifest-negative { + color: #b00020; +} + +.manifest-trade-box { + display: flex; + flex-direction: row; + align-items: flex-start; +} + +.manifest-trade-good { + display: flex; + flex-direction: column; + align-items: flex-start; } .manifest-freight { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.manifest-row-break { + grid-column: 1 / -1; + height: 0; +} + +.manifest-delete { + background: none; + border: none; + color: #b00020; + cursor: pointer; + padding: 0; + font-family: inherit; + font-size: 10pt; + margin-right: 0.5rem; + align-self: flex-start; + +} + +.manifest-delete-icon { + display: inline-block; + width: 14px; + height: 14px; + border: 1px solid #b00020; + color: #b00020; + font-weight: 700; + line-height: 12px; + text-align: center; + font-size: 10px; + border-radius: 2px; + box-sizing: border-box; } .freight-item { - padding: 0.25rem 0.5rem; - border: 1px solid #555; - border-radius: 3px; - background: rgba(255, 255, 255, 0.05); - font-size: 10pt; + padding: 0.25rem 0.5rem; + border: 1px solid #555; + border-radius: 3px; + background: rgba(255, 255, 255, 0.05); + font-size: 10pt; } .manifest-goods { - display: flex; - flex-direction: column; - gap: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; } .goods-item { - display: flex; - justify-content: space-between; - padding: 0.25rem 0.5rem; - border: 1px solid #555; - border-radius: 3px; - background: rgba(255, 255, 255, 0.05); + display: flex; + justify-content: space-between; + padding: 0.25rem 0.5rem; + border: 1px solid #555; + border-radius: 3px; + background: rgba(255, 255, 255, 0.05); } .manifest-button { - background: rgba(255, 255, 255, 0.05); - border: 1px solid #555; - color: white; - cursor: pointer; - font-family: inherit; + background: rgba(255, 255, 255, 0.05); + border: 1px solid #555; + color: white; + cursor: pointer; + font-family: inherit; } .manifest-button:active { - background-image: linear-gradient(#3D94D9, #0067B9); - border: 1px solid #006DBC; + background-image: linear-gradient(#3d94d9, #0067b9); + border: 1px solid #006dbc; } .manifest-summary { - margin-bottom: 1rem; - padding: 0.5rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid #555; - border-radius: 3px; - font-size: 10pt; + margin-bottom: 1rem; + padding: 0.5rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid #555; + border-radius: 3px; + font-size: 10pt; } .summary-line { - text-align: center; + text-align: center; } .summary-line strong { - color: #51A9EE; + color: #51a9ee; } @media (max-width: 768px) { - .manifest-grid { - grid-template-columns: repeat(2, 1fr); - } + .manifest-grid { + grid-template-columns: repeat(2, 1fr); + } } @media (max-width: 768px) { @@ -608,34 +671,75 @@ input[type="text"] { } .purchased-input { - width: 60px; - background: rgba(255, 255, 255, 0.1); - border: 1px solid #555; - color: white; - padding: 0.25rem; - border-radius: 3px; - font-family: inherit; - text-align: center; + width: 60px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid #555; + color: white; + padding: 0.25rem; + border-radius: 3px; + font-family: inherit; + text-align: center; } .purchased-input:focus { - outline: none; - border-color: #3D94D9; - background: rgba(255, 255, 255, 0.15); + outline: none; + border-color: #3d94d9; + background: rgba(255, 255, 255, 0.15); } .purchased-input-active { - color: #51A9EE !important; + color: #51a9ee !important; } datalist { - font-size: 9pt; - line-height: 1.2; + font-size: 9pt; + line-height: 1.2; } datalist option { - padding: 2px 4px; - margin: 0; - line-height: 1.2; - font-size: 9pt; + padding: 2px 4px; + margin: 0; + line-height: 1.2; + font-size: 9pt; +} + +.trade-section { + font-size: 14pt; +} + +.badge-carried { + margin-left: 0.25rem; + padding: 0 0.3rem; + border: 1px solid #1976d2; + color: #1976d2; + border-radius: 2px; + font-size: 10px; +} + + +/* Trade header layout (replaces prior inline styles) */ +.trade-header-row { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} +.trade-header-title { + margin: 0 0 1rem 0; +} + +.trade-header-classifications { + font-weight: normal; + font-size: 16pt; + color: #888; +} + +.trade-illegal-toggle { + font-weight: normal; + font-size: .95rem; + color: #888; + display: flex; + align-items: center; + gap: .4rem; + margin: 0px 0px 24px 4px; } diff --git a/style.patch b/style.patch new file mode 100644 index 0000000..8ea0fbb --- /dev/null +++ b/style.patch @@ -0,0 +1,16 @@ +--- a/src/components/trade_computer.rs ++++ b/src/components/trade_computer.rs +@@ +-
+-

++
++

+@@ +- +- ++ ++ +@@ +-

"Discount" "Purchased"
"Buy Price" "Discount" "Purchased""Sell Price" "Discount" "Sell Qty"
"No goods available""No goods available"
- {good.name.clone()} - - "carried" - - {available_quantity.to_string()}{good.base_cost.to_string()}{good.buy_cost.to_string()} - {discount_percent.to_string()}"%" - - 0 { - "purchased-input purchased-input-active" - } else { - "purchased-input" - } - } - /> - {sell_price.to_string()} - {sell_discount_percent.to_string()}"%" - - {let good_index = good.source_entry.index; let good_clone = good.clone(); let sell_edit = RwSignal::new(ship_manifest.read().get_sell_amount_by_index(good_index)); view! { - ().unwrap_or(0); - let m = ship_manifest.read(); - let prev_amt = m.get_sell_amount_by_index(good_index); - let current_qty = m.get_trade_good_quantity_by_index(good_index); - let allowed_max = prev_amt + current_qty; - sell_edit.set(requested.clamp(0, allowed_max)); - } - on:change=move |_| { - let new_amt = sell_edit.get(); - let mut manifest = ship_manifest.write(); - let prev_amt = manifest.get_sell_amount_by_index(good_index); - let current_qty = manifest.get_trade_good_quantity_by_index(good_index); - let delta = new_amt - prev_amt; - if delta > 0 { - let new_qty = current_qty - delta; - if new_qty == 0 { - manifest.remove_trade_good_by_index(good_index); - } else { - manifest.update_trade_good(&good_clone, new_qty); - } - } else if delta < 0 { - let add_back = -delta; - let new_qty = current_qty + add_back; - manifest.update_trade_good(&good_clone, new_qty); - } - manifest.set_sell_amount_by_index(good_index, new_amt); - } - class=move || { - if sell_edit.get() > 0 { - "purchased-input purchased-input-active" - } else { - "purchased-input" - } - } - /> - }} -
- {good.name.clone()} - - "carried" - - {available_quantity.to_string()}{good.base_cost.to_string()}{good.buy_cost.to_string()} - {discount_percent.to_string()}"%" - - 0 { - "purchased-input purchased-input-active" - } else { - "purchased-input" - } - } - /> - "-""-"