use std::collections::HashSet; use bevy::prelude::*; use crate::{ climate::day_of, colonist::{MoveSpeed, Path}, economy::{Stock, in_colony}, items::{CARRY_CAPACITY, Item, ItemKind}, jobs::{Reservations, Target, Task}, pathfinding::{ColonyReach, find_path}, population::{ColonyLog, edge_tile}, raids::nearest_edge, rng::SimRng, sim::{SimSystems, SimTick, TICKS_PER_HOUR, sim_pos}, world::{TILE_SIZE, TilePos, WorldMap}, worldgen::WorldGenerated, zones::Zones, }; pub struct TradingPlugin; impl Plugin for TradingPlugin { fn build(&self, app: &mut App) { app.init_resource::() .add_systems(Startup, create_trader_assets) .add_systems(Update, clear_traders.run_if(on_message::)) .add_systems( FixedUpdate, (trader_arrivals, trader_moves) .chain() .in_set(SimSystems::Act), ); } } pub const FIRST_TRADE_DAY: u64 = 3; pub const TRADE_GAP_DAYS: u64 = 4; pub const TRADE_CHANCE: f32 = 0.6; pub const TRADE_STAY_TICKS: u64 = 12 * TICKS_PER_HOUR; pub const TRADE_WORK: u32 = 90; pub const TRADER_RATE: f32 = 0.8; pub const SURPLUS_MARGIN: u32 = 10; const TRADE_HOUR_TICK: u64 = 11 * TICKS_PER_HOUR; const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; const PLAN_INTERVAL_TICKS: u64 = 20; const TRADER_SPEED: f32 = 2.6; const ARRIVAL_ATTEMPTS: usize = 10; const TRADE_SEARCH_TILES: usize = 15000; const WARES: [ItemKind; 8] = [ ItemKind::Iron, ItemKind::Gold, ItemKind::Herbs, ItemKind::Tools, ItemKind::Meal, ItemKind::Stone, ItemKind::Wood, ItemKind::Vegetables, ]; #[derive(Component, Debug, Default, Clone, PartialEq)] #[require(Transform, Visibility, TilePos, Path)] pub struct Trader { pub stock: Vec, pub arrived: Option, pub leaving: bool, pub deals: u32, } impl Trader { pub fn open(&self) -> bool { self.arrived.is_some() && !self.leaving } pub fn has(&self, kind: ItemKind) -> u32 { self.stock .iter() .filter(|i| i.kind == kind) .map(|i| i.count) .sum() } pub fn trade(&mut self, offer: Item, want: ItemKind) -> Option { let receive = payment(offer.kind, offer.count, want).min(self.has(want)); if receive == 0 { return None; } let ware = self.stock.iter_mut().find(|i| i.kind == want)?; ware.count -= receive; self.stock.retain(|i| i.count > 0); self.deals += 1; Some(Item::new(want, receive)) } } #[derive(Resource, Default, Debug)] pub struct TradeState { pub last_day: Option, } #[derive(Resource, Default)] pub struct TraderAssets { body: (Handle, Handle), pack: (Handle, Handle), } pub fn value(kind: ItemKind) -> f32 { match kind { ItemKind::Wood | ItemKind::Stone => 1.0, ItemKind::Berries | ItemKind::Meat | ItemKind::Vegetables => 1.0, ItemKind::Iron => 4.0, ItemKind::Herbs => 3.0, ItemKind::Meal => 4.0, ItemKind::Gold => 8.0, ItemKind::Tools => 15.0, ItemKind::Carcass => 0.0, } } pub fn payment(give: ItemKind, count: u32, want: ItemKind) -> u32 { (count as f32 * value(give) * TRADER_RATE / value(want)).floor() as u32 } pub fn trade_due(day: u64, last: Option) -> bool { day >= FIRST_TRADE_DAY && last.is_none_or(|last| day >= last + TRADE_GAP_DAYS) } pub fn wares(rng: &mut SimRng) -> Vec { let mut kinds = WARES.to_vec(); let mut stock = Vec::new(); for _ in 0..4 { let kind = kinds.remove(rng.range_i32(0..kinds.len() as i32) as usize); let count = match kind { ItemKind::Tools => rng.range_i32(3..7), ItemKind::Gold | ItemKind::Herbs => rng.range_i32(10..25), _ => rng.range_i32(25..60), }; stock.push(Item::new(kind, count as u32)); } stock } #[derive(Debug, Clone, Copy, PartialEq)] pub struct Deal { pub give: ItemKind, pub count: u32, pub want: ItemKind, pub receive: u32, } pub fn surplus(have: u32, goal: u32) -> u32 { have.saturating_sub((goal * 3 / 2).max(goal + SURPLUS_MARGIN)) } pub fn plan_trade( have: &dyn Fn(Stock) -> u32, goal: &dyn Fn(Stock) -> u32, wares: &[Item], stack: &dyn Fn(ItemKind) -> u32, ) -> Option { let (want, deficit) = wares .iter() .map(|w| { let stock = Stock::of(w.kind); let deficit = goal(stock).saturating_sub(have(stock)).min(w.count); (w.kind, deficit) }) .filter(|&(_, deficit)| deficit > 0) .max_by(|a, b| (a.1 as f32 * value(a.0)).total_cmp(&(b.1 as f32 * value(b.0))))?; let wanted = deficit.min(CARRY_CAPACITY); ItemKind::ALL .iter() .filter(|&&give| value(give) > 0.0 && Stock::of(give) != Stock::of(want)) .filter_map(|&give| { let spare = surplus(have(Stock::of(give)), goal(Stock::of(give))); let needed = (wanted as f32 * value(want) / (value(give) * TRADER_RATE)).ceil() as u32; let count = needed.min(spare).min(stack(give)).min(CARRY_CAPACITY); let receive = payment(give, count, want).min(wanted); let count = (1..=count) .find(|&n| payment(give, n, want) >= receive) .unwrap_or(count); (receive > 0).then_some(Deal { give, count, want, receive, }) }) .max_by(|a, b| { (a.receive, value(a.give) as u32, a.give.index()).cmp(&( b.receive, value(b.give) as u32, b.give.index(), )) }) } #[allow(clippy::too_many_arguments)] pub fn find_trade( map: &WorldMap, reservations: &Reservations, reach: &ColonyReach, items: &[(Item, TilePos)], home: UVec2, from: UVec2, trader: (Entity, &Trader), have: &dyn Fn(Stock) -> u32, goal: &dyn Fn(Stock) -> u32, ) -> Option { let (entity, trader) = trader; let free = |item: &Item, pos: &TilePos| { item.count > 0 && in_colony(home, pos.0) && reservations.is_free(Target::Pickup(pos.0)) }; let largest = |kind: ItemKind| { items .iter() .filter(|(item, pos)| item.kind == kind && free(item, pos)) .map(|(item, _)| item.count) .max() .unwrap_or(0) }; let deal = plan_trade(have, goal, &trader.stock, &largest)?; let stacks: HashSet = items .iter() .filter(|(item, pos)| item.kind == deal.give && item.count >= deal.count && free(item, pos)) .map(|(_, pos)| pos.0) .collect(); let pickup = reach.nearest(map, from, TRADE_SEARCH_TILES, &stacks)?; Some(Task::Trade { trader: entity, pickup: Some(pickup), give: deal.give, count: deal.count, want: deal.want, receive: deal.receive, progress: 0, }) } fn create_trader_assets( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, ) { commands.insert_resource(TraderAssets { body: ( meshes.add(Rectangle::new(TILE_SIZE * 0.5, TILE_SIZE * 0.6)), materials.add(Color::srgb_u8(40, 120, 130)), ), pack: ( meshes.add(Rectangle::new(TILE_SIZE * 0.4, TILE_SIZE * 0.45)), materials.add(Color::srgb_u8(160, 120, 70)), ), }); } fn clear_traders(mut commands: Commands, traders: Query>) { for trader in &traders { commands.entity(trader).despawn(); } } pub fn spawn_trader( commands: &mut Commands, assets: &TraderAssets, tile: UVec2, stock: Vec, ) -> Entity { let part = |(mesh, material): &(Handle, Handle), at: Transform| { (Mesh2d(mesh.clone()), MeshMaterial2d(material.clone()), at) }; commands .spawn(( Name::new("Trader"), Trader { stock, ..default() }, MoveSpeed(TRADER_SPEED), TilePos(tile), sim_pos(WorldMap::tile_center(tile)), Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)), children![ part( &assets.pack, Transform::from_xyz(0.0, TILE_SIZE * 0.2, -0.01) ), part(&assets.body, Transform::default()), ], )) .id() } pub fn describe_wares(stock: &[Item]) -> String { stock .iter() .map(|i| format!("{} {:?}", i.count, i.kind)) .collect::>() .join(", ") } #[allow(clippy::too_many_arguments)] fn trader_arrivals( mut commands: Commands, tick: Res, map: Res, zones: Res, assets: Res, mut rng: ResMut, mut state: ResMut, mut log: ResMut, ) { if tick.0 % DAY_TICKS != TRADE_HOUR_TICK || !zones.has_stockpile() { return; } let day = day_of(*tick); if !trade_due(day, state.last_day) || rng.range_f32(0.0..1.0) >= TRADE_CHANCE { return; } let home = zones.home(map.size / 2); let Some((tile, route)) = (0..ARRIVAL_ATTEMPTS).find_map(|_| { let tile = edge_tile(&map, &mut rng)?; find_path(&map, tile, home).map(|route| (tile, route)) }) else { return; }; let stock = wares(&mut rng); log.push( *tick, format!("A trader is coming with {}", describe_wares(&stock)), ); let trader = spawn_trader(&mut commands, &assets, tile, stock); commands.entity(trader).insert(Path(route.into())); state.last_day = Some(day); } fn trader_moves( mut commands: Commands, tick: Res, map: Res, mut log: ResMut, mut traders: Query<(Entity, &TilePos, &mut Path, &mut Trader)>, ) { if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) { return; } for (entity, tile, mut path, mut trader) in &mut traders { if trader.arrived.is_none() { if path.0.is_empty() { trader.arrived = Some(tick.0); } continue; } let stayed = trader .arrived .is_some_and(|at| tick.0 >= at + TRADE_STAY_TICKS); if !trader.leaving && (stayed || trader.stock.is_empty()) { trader.leaving = true; let deals = trader.deals; log.push(*tick, format!("The trader is leaving after {deals} deals")); } if !trader.leaving { continue; } let exit = nearest_edge(&map, tile.0); if tile.0 == exit && path.0.is_empty() { commands.entity(entity).despawn(); } else if path.0.back() != Some(&exit) && let Some(route) = find_path(&map, tile.0, exit) { path.0 = route.into(); } } } #[cfg(test)] mod tests { use super::*; // Caravans first come once the colony has a few days of goods, then every few days. #[test] fn traders_come_every_few_days() { assert!(!trade_due(FIRST_TRADE_DAY - 1, None)); assert!(trade_due(FIRST_TRADE_DAY, None)); assert!(!trade_due(10, Some(10 - TRADE_GAP_DAYS + 1))); assert!(trade_due(10, Some(10 - TRADE_GAP_DAYS))); } // Colonies only sell well above their goal (so they don't sell what they will need soon) // and buy what is below goal, paying the trader's margin. #[test] fn colonies_sell_surplus_for_what_they_lack() { let have = |s: Stock| match s { Stock::Wood => 120, Stock::Stone => 45, _ => 0, }; let goal = |s: Stock| match s { Stock::Wood | Stock::Stone => 40, Stock::Iron => 10, _ => 0, }; let wares = [Item::new(ItemKind::Iron, 30), Item::new(ItemKind::Gold, 5)]; let deal = plan_trade(&have, &goal, &wares, &|_| 50).unwrap(); assert_eq!(deal.give, ItemKind::Wood); assert_eq!(deal.want, ItemKind::Iron); assert_eq!(deal.receive, 5); assert_eq!(deal.count, 25); assert_eq!(surplus(45, 40), 0); let small = plan_trade(&have, &goal, &[Item::new(ItemKind::Meal, 1)], &|_| 9); assert_eq!(small.map(|d| d.count), None); let meal = |s: Stock| if s == Stock::Meals { 1 } else { goal(s) }; let small = plan_trade(&have, &meal, &[Item::new(ItemKind::Meal, 1)], &|_| 9).unwrap(); assert_eq!((small.count, small.receive), (5, 1)); assert!(plan_trade(&have, &|_| 0, &wares, &|_| 50).is_none()); assert!(plan_trade(&|_| 0, &goal, &wares, &|_| 50).is_none()); } #[test] fn traders_pay_from_their_stock() { let mut trader = Trader { stock: vec![Item::new(ItemKind::Iron, 3)], ..default() }; let got = trader.trade(Item::new(ItemKind::Wood, 25), ItemKind::Iron); assert_eq!(got.map(|i| i.count), Some(3)); assert!(trader.stock.is_empty()); assert_eq!( trader.trade(Item::new(ItemKind::Wood, 25), ItemKind::Iron), None ); } }