src/trading.rs 13.2 K · 430 lines · raw · history

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 // Caravans first come once the colony has a few days of goods, then every few days.
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 // Colonies only sell well above their goal (so they don't sell what they will need soon)
387 // and buy what is below goal, paying the trader's margin.
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 }