| 1 |
use std::collections::HashSet; |
| 2 |
|
| 3 |
use bevy::prelude::*; |
| 4 |
|
| 5 |
use crate::{ |
| 6 |
climate::day_of, |
| 7 |
colonist::{MoveSpeed, Path}, |
| 8 |
economy::{Stock, in_colony}, |
| 9 |
items::{CARRY_CAPACITY, Item, ItemKind}, |
| 10 |
jobs::{Reservations, Target, Task}, |
| 11 |
pathfinding::{ColonyReach, find_path}, |
| 12 |
population::{ColonyLog, edge_tile}, |
| 13 |
raids::nearest_edge, |
| 14 |
rng::SimRng, |
| 15 |
sim::{SimSystems, SimTick, TICKS_PER_HOUR, sim_pos}, |
| 16 |
world::{TILE_SIZE, TilePos, WorldMap}, |
| 17 |
worldgen::WorldGenerated, |
| 18 |
zones::Zones, |
| 19 |
}; |
| 20 |
|
| 21 |
pub struct TradingPlugin; |
| 22 |
|
| 23 |
impl Plugin for TradingPlugin { |
| 24 |
fn build(&self, app: &mut App) { |
| 25 |
app.init_resource::<TradeState>() |
| 26 |
.add_systems(Startup, create_trader_assets) |
| 27 |
.add_systems(Update, clear_traders.run_if(on_message::<WorldGenerated>)) |
| 28 |
.add_systems( |
| 29 |
FixedUpdate, |
| 30 |
(trader_arrivals, trader_moves) |
| 31 |
.chain() |
| 32 |
.in_set(SimSystems::Act), |
| 33 |
); |
| 34 |
} |
| 35 |
} |
| 36 |
|
| 37 |
pub const FIRST_TRADE_DAY: u64 = 3; |
| 38 |
pub const TRADE_GAP_DAYS: u64 = 4; |
| 39 |
pub const TRADE_CHANCE: f32 = 0.6; |
| 40 |
pub const TRADE_STAY_TICKS: u64 = 12 * TICKS_PER_HOUR; |
| 41 |
pub const TRADE_WORK: u32 = 90; |
| 42 |
pub const TRADER_RATE: f32 = 0.8; |
| 43 |
pub const SURPLUS_MARGIN: u32 = 10; |
| 44 |
const TRADE_HOUR_TICK: u64 = 11 * TICKS_PER_HOUR; |
| 45 |
const DAY_TICKS: u64 = 24 * TICKS_PER_HOUR; |
| 46 |
const PLAN_INTERVAL_TICKS: u64 = 20; |
| 47 |
const TRADER_SPEED: f32 = 2.6; |
| 48 |
const ARRIVAL_ATTEMPTS: usize = 10; |
| 49 |
const TRADE_SEARCH_TILES: usize = 15000; |
| 50 |
const WARES: [ItemKind; 8] = [ |
| 51 |
ItemKind::Iron, |
| 52 |
ItemKind::Gold, |
| 53 |
ItemKind::Herbs, |
| 54 |
ItemKind::Tools, |
| 55 |
ItemKind::Meal, |
| 56 |
ItemKind::Stone, |
| 57 |
ItemKind::Wood, |
| 58 |
ItemKind::Vegetables, |
| 59 |
]; |
| 60 |
|
| 61 |
#[derive(Component, Debug, Default, Clone, PartialEq)] |
| 62 |
#[require(Transform, Visibility, TilePos, Path)] |
| 63 |
pub struct Trader { |
| 64 |
pub stock: Vec<Item>, |
| 65 |
pub arrived: Option<u64>, |
| 66 |
pub leaving: bool, |
| 67 |
pub deals: u32, |
| 68 |
} |
| 69 |
|
| 70 |
impl Trader { |
| 71 |
pub fn open(&self) -> bool { |
| 72 |
self.arrived.is_some() && !self.leaving |
| 73 |
} |
| 74 |
|
| 75 |
pub fn has(&self, kind: ItemKind) -> u32 { |
| 76 |
self.stock |
| 77 |
.iter() |
| 78 |
.filter(|i| i.kind == kind) |
| 79 |
.map(|i| i.count) |
| 80 |
.sum() |
| 81 |
} |
| 82 |
|
| 83 |
pub fn trade(&mut self, offer: Item, want: ItemKind) -> Option<Item> { |
| 84 |
let receive = payment(offer.kind, offer.count, want).min(self.has(want)); |
| 85 |
if receive == 0 { |
| 86 |
return None; |
| 87 |
} |
| 88 |
let ware = self.stock.iter_mut().find(|i| i.kind == want)?; |
| 89 |
ware.count -= receive; |
| 90 |
self.stock.retain(|i| i.count > 0); |
| 91 |
self.deals += 1; |
| 92 |
Some(Item::new(want, receive)) |
| 93 |
} |
| 94 |
} |
| 95 |
|
| 96 |
#[derive(Resource, Default, Debug)] |
| 97 |
pub struct TradeState { |
| 98 |
pub last_day: Option<u64>, |
| 99 |
} |
| 100 |
|
| 101 |
#[derive(Resource, Default)] |
| 102 |
pub struct TraderAssets { |
| 103 |
body: (Handle<Mesh>, Handle<ColorMaterial>), |
| 104 |
pack: (Handle<Mesh>, Handle<ColorMaterial>), |
| 105 |
} |
| 106 |
|
| 107 |
pub fn value(kind: ItemKind) -> f32 { |
| 108 |
match kind { |
| 109 |
ItemKind::Wood | ItemKind::Stone => 1.0, |
| 110 |
ItemKind::Berries | ItemKind::Meat | ItemKind::Vegetables => 1.0, |
| 111 |
ItemKind::Iron => 4.0, |
| 112 |
ItemKind::Herbs => 3.0, |
| 113 |
ItemKind::Meal => 4.0, |
| 114 |
ItemKind::Gold => 8.0, |
| 115 |
ItemKind::Tools => 15.0, |
| 116 |
ItemKind::Carcass => 0.0, |
| 117 |
} |
| 118 |
} |
| 119 |
|
| 120 |
pub fn payment(give: ItemKind, count: u32, want: ItemKind) -> u32 { |
| 121 |
(count as f32 * value(give) * TRADER_RATE / value(want)).floor() as u32 |
| 122 |
} |
| 123 |
|
| 124 |
pub fn trade_due(day: u64, last: Option<u64>) -> bool { |
| 125 |
day >= FIRST_TRADE_DAY && last.is_none_or(|last| day >= last + TRADE_GAP_DAYS) |
| 126 |
} |
| 127 |
|
| 128 |
pub fn wares(rng: &mut SimRng) -> Vec<Item> { |
| 129 |
let mut kinds = WARES.to_vec(); |
| 130 |
let mut stock = Vec::new(); |
| 131 |
for _ in 0..4 { |
| 132 |
let kind = kinds.remove(rng.range_i32(0..kinds.len() as i32) as usize); |
| 133 |
let count = match kind { |
| 134 |
ItemKind::Tools => rng.range_i32(3..7), |
| 135 |
ItemKind::Gold | ItemKind::Herbs => rng.range_i32(10..25), |
| 136 |
_ => rng.range_i32(25..60), |
| 137 |
}; |
| 138 |
stock.push(Item::new(kind, count as u32)); |
| 139 |
} |
| 140 |
stock |
| 141 |
} |
| 142 |
|
| 143 |
#[derive(Debug, Clone, Copy, PartialEq)] |
| 144 |
pub struct Deal { |
| 145 |
pub give: ItemKind, |
| 146 |
pub count: u32, |
| 147 |
pub want: ItemKind, |
| 148 |
pub receive: u32, |
| 149 |
} |
| 150 |
|
| 151 |
pub fn surplus(have: u32, goal: u32) -> u32 { |
| 152 |
have.saturating_sub((goal * 3 / 2).max(goal + SURPLUS_MARGIN)) |
| 153 |
} |
| 154 |
|
| 155 |
pub fn plan_trade( |
| 156 |
have: &dyn Fn(Stock) -> u32, |
| 157 |
goal: &dyn Fn(Stock) -> u32, |
| 158 |
wares: &[Item], |
| 159 |
stack: &dyn Fn(ItemKind) -> u32, |
| 160 |
) -> Option<Deal> { |
| 161 |
let (want, deficit) = wares |
| 162 |
.iter() |
| 163 |
.map(|w| { |
| 164 |
let stock = Stock::of(w.kind); |
| 165 |
let deficit = goal(stock).saturating_sub(have(stock)).min(w.count); |
| 166 |
(w.kind, deficit) |
| 167 |
}) |
| 168 |
.filter(|&(_, deficit)| deficit > 0) |
| 169 |
.max_by(|a, b| (a.1 as f32 * value(a.0)).total_cmp(&(b.1 as f32 * value(b.0))))?; |
| 170 |
let wanted = deficit.min(CARRY_CAPACITY); |
| 171 |
ItemKind::ALL |
| 172 |
.iter() |
| 173 |
.filter(|&&give| value(give) > 0.0 && Stock::of(give) != Stock::of(want)) |
| 174 |
.filter_map(|&give| { |
| 175 |
let spare = surplus(have(Stock::of(give)), goal(Stock::of(give))); |
| 176 |
let needed = (wanted as f32 * value(want) / (value(give) * TRADER_RATE)).ceil() as u32; |
| 177 |
let count = needed.min(spare).min(stack(give)).min(CARRY_CAPACITY); |
| 178 |
let receive = payment(give, count, want).min(wanted); |
| 179 |
let count = (1..=count) |
| 180 |
.find(|&n| payment(give, n, want) >= receive) |
| 181 |
.unwrap_or(count); |
| 182 |
(receive > 0).then_some(Deal { |
| 183 |
give, |
| 184 |
count, |
| 185 |
want, |
| 186 |
receive, |
| 187 |
}) |
| 188 |
}) |
| 189 |
.max_by(|a, b| { |
| 190 |
(a.receive, value(a.give) as u32, a.give.index()).cmp(&( |
| 191 |
b.receive, |
| 192 |
value(b.give) as u32, |
| 193 |
b.give.index(), |
| 194 |
)) |
| 195 |
}) |
| 196 |
} |
| 197 |
|
| 198 |
#[allow(clippy::too_many_arguments)] |
| 199 |
pub fn find_trade( |
| 200 |
map: &WorldMap, |
| 201 |
reservations: &Reservations, |
| 202 |
reach: &ColonyReach, |
| 203 |
items: &[(Item, TilePos)], |
| 204 |
home: UVec2, |
| 205 |
from: UVec2, |
| 206 |
trader: (Entity, &Trader), |
| 207 |
have: &dyn Fn(Stock) -> u32, |
| 208 |
goal: &dyn Fn(Stock) -> u32, |
| 209 |
) -> Option<Task> { |
| 210 |
let (entity, trader) = trader; |
| 211 |
let free = |item: &Item, pos: &TilePos| { |
| 212 |
item.count > 0 && in_colony(home, pos.0) && reservations.is_free(Target::Pickup(pos.0)) |
| 213 |
}; |
| 214 |
let largest = |kind: ItemKind| { |
| 215 |
items |
| 216 |
.iter() |
| 217 |
.filter(|(item, pos)| item.kind == kind && free(item, pos)) |
| 218 |
.map(|(item, _)| item.count) |
| 219 |
.max() |
| 220 |
.unwrap_or(0) |
| 221 |
}; |
| 222 |
let deal = plan_trade(have, goal, &trader.stock, &largest)?; |
| 223 |
let stacks: HashSet<UVec2> = items |
| 224 |
.iter() |
| 225 |
.filter(|(item, pos)| item.kind == deal.give && item.count >= deal.count && free(item, pos)) |
| 226 |
.map(|(_, pos)| pos.0) |
| 227 |
.collect(); |
| 228 |
let pickup = reach.nearest(map, from, TRADE_SEARCH_TILES, &stacks)?; |
| 229 |
Some(Task::Trade { |
| 230 |
trader: entity, |
| 231 |
pickup: Some(pickup), |
| 232 |
give: deal.give, |
| 233 |
count: deal.count, |
| 234 |
want: deal.want, |
| 235 |
receive: deal.receive, |
| 236 |
progress: 0, |
| 237 |
}) |
| 238 |
} |
| 239 |
|
| 240 |
fn create_trader_assets( |
| 241 |
mut commands: Commands, |
| 242 |
mut meshes: ResMut<Assets<Mesh>>, |
| 243 |
mut materials: ResMut<Assets<ColorMaterial>>, |
| 244 |
) { |
| 245 |
commands.insert_resource(TraderAssets { |
| 246 |
body: ( |
| 247 |
meshes.add(Rectangle::new(TILE_SIZE * 0.5, TILE_SIZE * 0.6)), |
| 248 |
materials.add(Color::srgb_u8(40, 120, 130)), |
| 249 |
), |
| 250 |
pack: ( |
| 251 |
meshes.add(Rectangle::new(TILE_SIZE * 0.4, TILE_SIZE * 0.45)), |
| 252 |
materials.add(Color::srgb_u8(160, 120, 70)), |
| 253 |
), |
| 254 |
}); |
| 255 |
} |
| 256 |
|
| 257 |
fn clear_traders(mut commands: Commands, traders: Query<Entity, With<Trader>>) { |
| 258 |
for trader in &traders { |
| 259 |
commands.entity(trader).despawn(); |
| 260 |
} |
| 261 |
} |
| 262 |
|
| 263 |
pub fn spawn_trader( |
| 264 |
commands: &mut Commands, |
| 265 |
assets: &TraderAssets, |
| 266 |
tile: UVec2, |
| 267 |
stock: Vec<Item>, |
| 268 |
) -> Entity { |
| 269 |
let part = |(mesh, material): &(Handle<Mesh>, Handle<ColorMaterial>), at: Transform| { |
| 270 |
(Mesh2d(mesh.clone()), MeshMaterial2d(material.clone()), at) |
| 271 |
}; |
| 272 |
commands |
| 273 |
.spawn(( |
| 274 |
Name::new("Trader"), |
| 275 |
Trader { stock, ..default() }, |
| 276 |
MoveSpeed(TRADER_SPEED), |
| 277 |
TilePos(tile), |
| 278 |
sim_pos(WorldMap::tile_center(tile)), |
| 279 |
Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)), |
| 280 |
children![ |
| 281 |
part( |
| 282 |
&assets.pack, |
| 283 |
Transform::from_xyz(0.0, TILE_SIZE * 0.2, -0.01) |
| 284 |
), |
| 285 |
part(&assets.body, Transform::default()), |
| 286 |
], |
| 287 |
)) |
| 288 |
.id() |
| 289 |
} |
| 290 |
|
| 291 |
pub fn describe_wares(stock: &[Item]) -> String { |
| 292 |
stock |
| 293 |
.iter() |
| 294 |
.map(|i| format!("{} {:?}", i.count, i.kind)) |
| 295 |
.collect::<Vec<_>>() |
| 296 |
.join(", ") |
| 297 |
} |
| 298 |
|
| 299 |
#[allow(clippy::too_many_arguments)] |
| 300 |
fn trader_arrivals( |
| 301 |
mut commands: Commands, |
| 302 |
tick: Res<SimTick>, |
| 303 |
map: Res<WorldMap>, |
| 304 |
zones: Res<Zones>, |
| 305 |
assets: Res<TraderAssets>, |
| 306 |
mut rng: ResMut<SimRng>, |
| 307 |
mut state: ResMut<TradeState>, |
| 308 |
mut log: ResMut<ColonyLog>, |
| 309 |
) { |
| 310 |
if tick.0 % DAY_TICKS != TRADE_HOUR_TICK || !zones.has_stockpile() { |
| 311 |
return; |
| 312 |
} |
| 313 |
let day = day_of(*tick); |
| 314 |
if !trade_due(day, state.last_day) || rng.range_f32(0.0..1.0) >= TRADE_CHANCE { |
| 315 |
return; |
| 316 |
} |
| 317 |
let home = zones.home(map.size / 2); |
| 318 |
let Some((tile, route)) = (0..ARRIVAL_ATTEMPTS).find_map(|_| { |
| 319 |
let tile = edge_tile(&map, &mut rng)?; |
| 320 |
find_path(&map, tile, home).map(|route| (tile, route)) |
| 321 |
}) else { |
| 322 |
return; |
| 323 |
}; |
| 324 |
let stock = wares(&mut rng); |
| 325 |
log.push( |
| 326 |
*tick, |
| 327 |
format!("A trader is coming with {}", describe_wares(&stock)), |
| 328 |
); |
| 329 |
let trader = spawn_trader(&mut commands, &assets, tile, stock); |
| 330 |
commands.entity(trader).insert(Path(route.into())); |
| 331 |
state.last_day = Some(day); |
| 332 |
} |
| 333 |
|
| 334 |
fn trader_moves( |
| 335 |
mut commands: Commands, |
| 336 |
tick: Res<SimTick>, |
| 337 |
map: Res<WorldMap>, |
| 338 |
mut log: ResMut<ColonyLog>, |
| 339 |
mut traders: Query<(Entity, &TilePos, &mut Path, &mut Trader)>, |
| 340 |
) { |
| 341 |
if !tick.0.is_multiple_of(PLAN_INTERVAL_TICKS) { |
| 342 |
return; |
| 343 |
} |
| 344 |
for (entity, tile, mut path, mut trader) in &mut traders { |
| 345 |
if trader.arrived.is_none() { |
| 346 |
if path.0.is_empty() { |
| 347 |
trader.arrived = Some(tick.0); |
| 348 |
} |
| 349 |
continue; |
| 350 |
} |
| 351 |
let stayed = trader |
| 352 |
.arrived |
| 353 |
.is_some_and(|at| tick.0 >= at + TRADE_STAY_TICKS); |
| 354 |
if !trader.leaving && (stayed || trader.stock.is_empty()) { |
| 355 |
trader.leaving = true; |
| 356 |
let deals = trader.deals; |
| 357 |
log.push(*tick, format!("The trader is leaving after {deals} deals")); |
| 358 |
} |
| 359 |
if !trader.leaving { |
| 360 |
continue; |
| 361 |
} |
| 362 |
let exit = nearest_edge(&map, tile.0); |
| 363 |
if tile.0 == exit && path.0.is_empty() { |
| 364 |
commands.entity(entity).despawn(); |
| 365 |
} else if path.0.back() != Some(&exit) |
| 366 |
&& let Some(route) = find_path(&map, tile.0, exit) |
| 367 |
{ |
| 368 |
path.0 = route.into(); |
| 369 |
} |
| 370 |
} |
| 371 |
} |
| 372 |
|
| 373 |
#[cfg(test)] |
| 374 |
mod tests { |
| 375 |
use super::*; |
| 376 |
|
| 377 |
|
| 378 |
#[test] |
| 379 |
fn traders_come_every_few_days() { |
| 380 |
assert!(!trade_due(FIRST_TRADE_DAY - 1, None)); |
| 381 |
assert!(trade_due(FIRST_TRADE_DAY, None)); |
| 382 |
assert!(!trade_due(10, Some(10 - TRADE_GAP_DAYS + 1))); |
| 383 |
assert!(trade_due(10, Some(10 - TRADE_GAP_DAYS))); |
| 384 |
} |
| 385 |
|
| 386 |
|
| 387 |
|
| 388 |
#[test] |
| 389 |
fn colonies_sell_surplus_for_what_they_lack() { |
| 390 |
let have = |s: Stock| match s { |
| 391 |
Stock::Wood => 120, |
| 392 |
Stock::Stone => 45, |
| 393 |
_ => 0, |
| 394 |
}; |
| 395 |
let goal = |s: Stock| match s { |
| 396 |
Stock::Wood | Stock::Stone => 40, |
| 397 |
Stock::Iron => 10, |
| 398 |
_ => 0, |
| 399 |
}; |
| 400 |
let wares = [Item::new(ItemKind::Iron, 30), Item::new(ItemKind::Gold, 5)]; |
| 401 |
let deal = plan_trade(&have, &goal, &wares, &|_| 50).unwrap(); |
| 402 |
assert_eq!(deal.give, ItemKind::Wood); |
| 403 |
assert_eq!(deal.want, ItemKind::Iron); |
| 404 |
assert_eq!(deal.receive, 5); |
| 405 |
assert_eq!(deal.count, 25); |
| 406 |
assert_eq!(surplus(45, 40), 0); |
| 407 |
let small = plan_trade(&have, &goal, &[Item::new(ItemKind::Meal, 1)], &|_| 9); |
| 408 |
assert_eq!(small.map(|d| d.count), None); |
| 409 |
let meal = |s: Stock| if s == Stock::Meals { 1 } else { goal(s) }; |
| 410 |
let small = plan_trade(&have, &meal, &[Item::new(ItemKind::Meal, 1)], &|_| 9).unwrap(); |
| 411 |
assert_eq!((small.count, small.receive), (5, 1)); |
| 412 |
assert!(plan_trade(&have, &|_| 0, &wares, &|_| 50).is_none()); |
| 413 |
assert!(plan_trade(&|_| 0, &goal, &wares, &|_| 50).is_none()); |
| 414 |
} |
| 415 |
|
| 416 |
#[test] |
| 417 |
fn traders_pay_from_their_stock() { |
| 418 |
let mut trader = Trader { |
| 419 |
stock: vec![Item::new(ItemKind::Iron, 3)], |
| 420 |
..default() |
| 421 |
}; |
| 422 |
let got = trader.trade(Item::new(ItemKind::Wood, 25), ItemKind::Iron); |
| 423 |
assert_eq!(got.map(|i| i.count), Some(3)); |
| 424 |
assert!(trader.stock.is_empty()); |
| 425 |
assert_eq!( |
| 426 |
trader.trade(Item::new(ItemKind::Wood, 25), ItemKind::Iron), |
| 427 |
None |
| 428 |
); |
| 429 |
} |
| 430 |
} |