Add trading caravans

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-25 19:40 UTC
commit
a2ad790e5c62029bbc4bb02210681ed1660cc98c
parent
09e56887db
tree
browse at this commit

8 files changed +607 -7

CLAUDE.md +1 -0

@@ -54,6 +54,7 @@ DF/RimWorld-style colony sim, Rust + Bevy 0.20 (newer than training data — che
54 54 - `src/threats.rs` wolves and health. `AnimalKind::Wolf` (`is_predator`, population 3, `migration_ticks` 24 h vs 2 h, not tameable, meat 12) spawns with `Predator{target, fed_until, cooldown}`. `Health(f32)` required on Colonist and Animal. `prowl` (Decide, every 30 ticks): unfed wolves `pick_prey` (nearest within PROWL_RANGE 24, colonists cost +COLONIST_DETOUR 6) and path adjacent; `bite` (Act): adjacent, cooldown 60 → colonist −0.25 (log on first bite; death drops load, releases, Losses, log), animal −0.5 (despawn); a kill feeds the wolf FED_TICKS 12 h. `heal` (every 60 ticks): 0.3/day, ×3 asleep in a bed. Wild prey flees wolves too (wander threats include predators); wolves skip flee/wander while targeting. choose_jobs: `nearest_wolf`; a wolf within DANGER_RANGE 6 makes colonists ready and interrupts autonomous jobs (except wolf hunts/MoveTo); fighters (adult, health ≥ INJURED 0.5, Food work allowed, not exhausted/hungry) Hunt a reachable (`ColonyReach::allows_near`) unreserved wolf in danger range or within DEFEND_RANGE 15 of colony home (first candidate); others in danger flee (`flee_from`, 10 tiles away). Mood −0.1 below HURT 0.75; selection shows health; saved in ColonistSave.
55 55 - Medicine: `Plant::Herb` (worldgen 1% of fertile tiles, ripens in 3 days, harvest → 3 `ItemKind::Herbs`, Stock::Medicine "Herbs", min goal 4, no spoilage). `Skill::Medicine`, `WorkType::Doctor`. choose_jobs collects patients (`needs_tending`: health < HURT and no active `Tended`) via a query in the `plans` tuple; Doctor candidate (2nd, after refuel) → `find_tending` (nearest reachable unreserved patient ≠ self, `Target::Patient`; herb pickup via `ColonyReach::nearest_unbounded`, optional). `Task::Tend{patient, pickup, progress}`: fetch 1 herb, walk adjacent, TEND_WORK → insert `Tended{until: +1 day, quality: tend_quality(speed(Medicine), herb)}`; `heal` multiplies by 1 + 2·quality. Colonists below INJURED choose Sleep (bed rest) and keep sleeping until health ≥ INJURED.
56 56 - `src/raids.rs` raiders. `raid_arrivals` daily at 09:00 once a stockpile exists: `raid_due` (day ≥ FIRST_RAID_DAY 10, ≥ RAID_GAP_DAYS 6 since `RaidState.last_day`) and RAID_CHANCE 0.5 → `raid_size` (2 + colonists/12, ≤ 8) `Raider{loot, fleeing, cooldown}` entities (Health, MoveSpeed, dark red body + blade) at an `edge_tile` with a path home. `raider_plans` (every 20 ticks): below RAIDER_FLEE_HEALTH or carrying loot → walk to `nearest_edge`, despawn there (logs loot); else target the nearest in-colony item stack (keep the arrival route while > CLOSE_IN 20 away, then path adjacent ≤ RAIDER_SEARCH), take it when adjacent; no target → flee. `raider_attacks`: adjacent colonist −RAIDER_HIT per 60 ticks (death logged, Losses). `raider_deaths` drops loot. Colonists: raiders join wolves in choose_jobs' threat list (danger/defend/flee); defending a raider is `Task::Fight{enemy, progress}` (no reservation, several fighters allowed): within HUNT_RANGE + LOS every SHOT_WORK work units −SHOT_DAMAGE (rate includes Hunting skill via `Skill::of`), else path up to FIGHT_STEPS toward it. Raiders aren't saved (cleared on load and new world).
57 +- `src/trading.rs` caravans. `trader_arrivals` daily 11:00 once a stockpile exists (`trade_due`: day ≥ FIRST_TRADE_DAY 3, gap TRADE_GAP_DAYS 4, TRADE_CHANCE 0.6): `Trader{stock (`wares`: 4 random WARES kinds), arrived, leaving, deals}` walks from an `edge_tile` to colony home; `trader_moves` (every 20 ticks) marks arrival, leaves after TRADE_STAY_TICKS 12 h or when sold out (nearest_edge, despawn). `value(kind)` (Carcass 0 = not traded), `payment` = value × TRADER_RATE 0.8. `plan_trade(have, goal, wares, largest stack)`: want = ware with largest deficit×value (goal − have incl. pending), give = other stock group with `surplus` (have − max(1.5×goal, goal+SURPLUS_MARGIN)), count trimmed to the minimum that pays. jobs: `Task::Trade{trader, pickup, give, count, want, receive, progress}` (Haul work type, candidate before find_haul; `find_trade` with the open trader; pending counts `receive`, `selling` subtracts in-flight counts): fetch stack, walk adjacent, TRADE_WORK, `Trader::trade` swaps Carrying (logged), then normal delivery stores it. Traders aren't saved; hover shows wares.
57 58 - Arrivals get a real `Path` home (up to ARRIVAL_ATTEMPTS edge tiles tried); a bare MoveTo job without a path finishes at once, which used to strand newcomers at the map edge.
58 59 - Hearths: `StructureKind::Hearth` (6 stone, fuel 24 h, `refuel_cost` 4 wood vs build cost, light 3.5, heat 22 → warms the home), planned in every owned built home. `Recipe::stations()` (Meal: Campfire or Hearth; Tools: Workbench); `find_crafting(ctx, from, recipe, own_home)` only allows a Hearth inside the cook's own home and prefers it. `find_refuel` covers hearths anywhere. `find_haul` leaves Meal stacks on home tiles (`Homes::is_home`) so family meals stay home.
59 60 - Growth: `prosperous` and births need GROWTH_FOOD_DAYS 3 × colonists of stored nutrition (1× let hearth cooking boom births to 120 then famine). `find_food` takes `ColonyReach` (nearest by distance in reach); a colonist whose food search fails skips food searches and starvation urgency for FOOD_RETRY_TICKS 60 (`GatherMemo.food` per entity) — starving colonists re-searching every tick made a famine cost ~4.8 ms/tick.

src/jobs/mod.rs +121 -6

@@ -13,7 +13,7 @@ use crate::{
13 13 climate::{COLD_TEMP, WARM_TEMP, Warmth, outdoor_temperature, sowing_season, tile_temperature},
14 14 colonist::{Colonist, Path, repath},
15 15 designations::Designations,
16 - economy::{ColonyGoals, in_colony},
16 + economy::{ColonyGoals, Stock, in_colony},
17 17 family::{Child, Family, Home, Homes},
18 18 food::{EAT_TICKS, Hunger, STARVING, find_food, portion, wants_food},
19 19 items::{
@@ -32,6 +32,7 @@ use crate::{
32 32 search_reach,
33 33 },
34 34 paths::busy_path,
35 + population::ColonyLog,
35 36 raids::Raider,
36 37 rng::SimRng,
37 38 sim::{SimSystems, SimTick, hour_of_day},
@@ -39,6 +40,7 @@ use crate::{
39 40 social::{FRIEND, Relations},
40 41 structures::{Fuel, Structure, StructureAssets, StructureKind, spawn_structure},
41 42 threats::{Health, INJURED, TEND_WORK, TENDED_TICKS, Tended, needs_tending, tend_quality},
43 + trading::{TRADE_WORK, Trader, find_trade},
42 44 world::{Plant, Terrain, TileChanged, TilePos, WorldMap},
43 45 worldgen::WorldGenerated,
44 46 zones::{
@@ -216,6 +218,15 @@ pub enum Task {
216 218 kind: AnimalKind,
217 219 progress: u32,
218 220 },
221 + Trade {
222 + trader: Entity,
223 + pickup: Option<UVec2>,
224 + give: ItemKind,
225 + count: u32,
226 + want: ItemKind,
227 + receive: u32,
228 + progress: u32,
229 + },
219 230 }
220 231
221 232 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -282,6 +293,7 @@ impl Task {
282 293 | Task::Tame { progress, .. }
283 294 | Task::Tend { progress, .. }
284 295 | Task::Fight { progress, .. }
296 + | Task::Trade { progress, .. }
285 297 | Task::Eat { progress, .. } => Some(progress),
286 298 _ => None,
287 299 }
@@ -532,6 +544,11 @@ fn reserve_task(reservations: &mut Reservations, task: Task, by: Entity) {
532 544 }
533 545 Task::Butcher { site, .. } => reservations.reserve(Target::Pickup(site), by),
534 546 Task::Equip { pickup } => reservations.reserve(Target::Pickup(pickup), by),
547 + Task::Trade { pickup, .. } => {
548 + if let Some(pickup) = pickup {
549 + reservations.reserve(Target::Pickup(pickup), by);
550 + }
551 + }
535 552 Task::Tend {
536 553 patient, pickup, ..
537 554 } => {
@@ -1356,10 +1373,12 @@ fn choose_jobs(
1356 1373 Query<(Entity, &TilePos, &Health, Option<&Tended>), With<Colonist>>,
1357 1374 Query<(Entity, &TilePos), With<Raider>>,
1358 1375 Res<Relations>,
1376 + Query<(Entity, &Trader)>,
1359 1377 ),
1360 1378 mut gather_memo: Local<GatherMemo>,
1361 1379 ) {
1362 - let (homes, designations, reach, patient_query, raider_query, relations) = plans;
1380 + let (homes, designations, reach, patient_query, raider_query, relations, trader_query) = plans;
1381 + let trader = trader_query.iter().find(|(_, t)| t.open());
1363 1382 let whereabouts: HashMap<Entity, UVec2> = patient_query
1364 1383 .iter()
1365 1384 .map(|(e, pos, ..)| (e, pos.0))
@@ -1444,9 +1463,16 @@ fn choose_jobs(
1444 1463 match job.task {
1445 1464 Task::Hunt { kind, .. } => *pending.entry(ItemKind::Meat).or_insert(0) += kind.meat(),
1446 1465 Task::Craft { recipe, .. } => *pending.entry(recipe.output()).or_insert(0) += 1,
1466 + Task::Trade { want, receive, .. } => *pending.entry(want).or_insert(0) += receive,
1447 1467 _ => {}
1448 1468 }
1449 1469 }
1470 + let mut selling = [0u32; ItemKind::ALL.len()];
1471 + for job in colonists.iter().filter_map(|c| c.3.0) {
1472 + if let Task::Trade { give, count, .. } = job.task {
1473 + selling[give.index()] += count;
1474 + }
1475 + }
1450 1476 let mut food_tiles: Option<HashSet<UVec2>> = None;
1451 1477 let hour = hour_of_day(*tick);
1452 1478 let mut occupied: Option<HashSet<UVec2>> = None;
@@ -1669,7 +1695,7 @@ fn choose_jobs(
1669 1695 let gather_failed = std::cell::Cell::new(false);
1670 1696 let forage_failed = std::cell::Cell::new(false);
1671 1697 let haul_failed = std::cell::Cell::new(false);
1672 - let candidates: [(WorkType, Option<Skill>, &dyn Fn() -> Option<Task>); 17] = [
1698 + let candidates: [(WorkType, Option<Skill>, &dyn Fn() -> Option<Task>); 18] = [
1673 1699 (WorkType::Haul, None, &|| {
1674 1700 find_expansion(&map, &zones, &reservations, &snapshot, &blueprints)
1675 1701 }),
@@ -1746,6 +1772,29 @@ fn choose_jobs(
1746 1772 .flatten()
1747 1773 }),
1748 1774 (WorkType::Haul, None, &|| {
1775 + let trader = trader?;
1776 + let have = |stock: Stock| {
1777 + let mut total = 0;
1778 + for kind in stock.members() {
1779 + total += stocks[&kind] + pending.get(&kind).copied().unwrap_or(0);
1780 + total = total.saturating_sub(selling[kind.index()]);
1781 + }
1782 + total
1783 + };
1784 + let goal = |stock: Stock| goals.goal(stock);
1785 + find_trade(
1786 + &map,
1787 + &reservations,
1788 + &reach,
1789 + &snapshot,
1790 + home,
1791 + tile.0,
1792 + trader,
1793 + &have,
1794 + &goal,
1795 + )
1796 + }),
1797 + (WorkType::Haul, None, &|| {
1749 1798 if skip_haul {
1750 1799 return None;
1751 1800 }
@@ -1906,8 +1955,13 @@ fn run_jobs(
1906 1955 creatures: (
1907 1956 Query<(&TilePos, &Path), (With<Animal>, Without<Colonist>)>,
1908 1957 Query<(&TilePos, &mut Health), (With<Raider>, Without<Colonist>)>,
1958 + Query<(&TilePos, &mut Trader), Without<Colonist>>,
1959 + ),
1960 + messages: (
1961 + MessageWriter<TileChanged>,
1962 + MessageWriter<Ate>,
1963 + ResMut<ColonyLog>,
1909 1964 ),
1910 - messages: (MessageWriter<TileChanged>, MessageWriter<Ate>),
1911 1965 mut colonists: Query<
1912 1966 (
1913 1967 Entity,
@@ -1925,8 +1979,8 @@ fn run_jobs(
1925 1979 With<Colonist>,
1926 1980 >,
1927 1981 ) {
1928 - let (mut changed, mut ate) = messages;
1929 - let (animals, mut raiders) = creatures;
1982 + let (mut changed, mut ate, mut log) = messages;
1983 + let (animals, mut raiders, mut traders) = creatures;
1930 1984 let hour = hour_of_day(*tick);
1931 1985 let tending = colonists
1932 1986 .iter()
@@ -2044,6 +2098,67 @@ fn run_jobs(
2044 2098 }
2045 2099 }
2046 2100 }
2101 + Task::Trade {
2102 + trader,
2103 + pickup,
2104 + give,
2105 + count,
2106 + want,
2107 + progress,
2108 + ..
2109 + } => match traders.get_mut(*trader) {
2110 + Ok((_, t)) if !t.open() => Step::Done,
2111 + Ok(_) if pickup.is_some() => {
2112 + let from = pickup.expect("checked above");
2113 + if reservations.holder(Target::Pickup(from)) != Some(entity) {
2114 + Step::Done
2115 + } else if tile.0 != from {
2116 + walk_to(&mut map, tile.0, from, &mut path)
2117 + } else {
2118 + reservations.release(Target::Pickup(from));
2119 + *pickup = None;
2120 + let load = take_items(&mut commands, &mut items, from, *count);
2121 + carrying.0 = load.filter(|l| l.kind == *give && l.count > 0);
2122 + if carrying.0.is_some() {
2123 + Step::Continue
2124 + } else {
2125 + Step::Done
2126 + }
2127 + }
2128 + }
2129 + Ok((at, _))
2130 + if at.0 != tile.0 && !is_adjacent(tile.0.as_ivec2(), at.0.as_ivec2()) =>
2131 + {
2132 + match find_path_adjacent(&map, tile.0, at.0) {
2133 + Some(p) if !p.is_empty() => {
2134 + path.0 = p.into();
2135 + Step::Continue
2136 + }
2137 + _ => Step::Done,
2138 + }
2139 + }
2140 + Ok((_, mut t)) => {
2141 + *progress += rate;
2142 + if *progress < TRADE_WORK {
2143 + Step::Continue
2144 + } else {
2145 + if let Some(offer) = carrying.0.filter(|l| l.kind == *give)
2146 + && let Some(got) = t.trade(offer, *want)
2147 + {
2148 + carrying.0 = Some(got);
2149 + log.push(
2150 + *tick,
2151 + format!(
2152 + "Traded {} {:?} for {} {:?}",
2153 + offer.count, offer.kind, got.count, got.kind
2154 + ),
2155 + );
2156 + }
2157 + Step::Done
2158 + }
2159 + }
2160 + Err(_) => Step::Done,
2161 + },
2047 2162 Task::Fight { enemy, progress } => match raiders.get_mut(*enemy) {
2048 2163 Ok((at, mut enemy_health)) if enemy_health.0 > 0.0 => {
2049 2164 let at = at.0;

src/jobs/tests.rs +26 -0

@@ -24,6 +24,7 @@ fn test_app(map: WorldMap) -> App {
24 24 .init_resource::<crate::pathfinding::ColonyReach>()
25 25 .init_resource::<crate::rooms::Rooms>()
26 26 .init_resource::<crate::social::Relations>()
27 + .init_resource::<crate::population::ColonyLog>()
27 28 .add_message::<crate::mood::Ate>()
28 29 .init_resource::<crate::designations::Designations>()
29 30 .init_resource::<ColonyGoals>()
@@ -1683,3 +1684,28 @@ fn colonists_fight_raiders() {
1683 1684 let health = app.world().get::<crate::threats::Health>(raider).unwrap();
1684 1685 assert!(health.0 <= 0.0, "{health:?}");
1685 1686 }
1687 +
1688 +// Colonists keep trading surplus while it lasts, but never sell below the surplus floor
1689 +// (goal + SURPLUS_MARGIN here), so trading can't strip the colony of what it needs.
1690 +#[test]
1691 +fn colonists_trade_surplus_for_wanted_goods() {
1692 + let mut app = hauling_app((IVec2::new(15, 15), IVec2::new(17, 15)));
1693 + set_goals(&mut app, &[(ItemKind::Wood, 10), (ItemKind::Iron, 10)]);
1694 + put_wood(&mut app, UVec2::new(15, 15), 50);
1695 + let trader = app
1696 + .world_mut()
1697 + .spawn((
1698 + Trader {
1699 + stock: vec![Item::new(ItemKind::Iron, 30)],
1700 + arrived: Some(0),
1701 + ..default()
1702 + },
1703 + TilePos(UVec2::new(10, 10)),
1704 + ))
1705 + .id();
1706 + spawn_colonist(&mut app, UVec2::new(2, 5));
1707 + run_ticks(&mut app, 60 * 30);
1708 + assert_eq!(items_of(&mut app, ItemKind::Iron), 6);
1709 + assert_eq!(items_of(&mut app, ItemKind::Wood), 20);
1710 + assert_eq!(app.world().get::<Trader>(trader).unwrap().deals, 2);
1711 +}

src/main.rs +2 -0

@@ -31,6 +31,7 @@ mod social;
31 31 mod structures;
32 32 mod threats;
33 33 mod tilemap;
34 +mod trading;
34 35 mod ui;
35 36 mod world;
36 37 mod worldgen;
@@ -91,6 +92,7 @@ fn main() {
91 92 rooms::RoomsPlugin,
92 93 raids::RaidsPlugin,
93 94 social::SocialPlugin,
95 + trading::TradingPlugin,
94 96 ))
95 97 .run();
96 98 }

src/save.rs +1 -0

@@ -265,6 +265,7 @@ pub fn apply(world: &mut World, save: SaveGame) {
265 265 despawn_all::<Item>(world);
266 266 despawn_all::<Structure>(world);
267 267 despawn_all::<crate::raids::Raider>(world);
268 + despawn_all::<crate::trading::Trader>(world);
268 269 world.insert_resource(SimTick(save.tick));
269 270 world.insert_resource(save.rng);
270 271 world.insert_resource(Zones::load(save.zones));

src/selection.rs +25 -1

@@ -23,6 +23,7 @@ use crate::{
23 23 social::{FRIEND, RIVAL, Relations},
24 24 structures::{Fuel, Structure, StructureKind},
25 25 threats::{Health, TEND_WORK},
26 + trading::{TRADE_WORK, Trader, describe_wares},
26 27 ui::{HideableUi, PointerOverUi},
27 28 world::{TILE_SIZE, TilePos, WorldMap},
28 29 worldgen::{WorldGenerated, WorldSeed},
@@ -276,6 +277,7 @@ fn update_tile_info(
276 277 Res<DesignateTool>,
277 278 Res<Rooms>,
278 279 Res<Relations>,
280 + Query<(&Trader, &TilePos)>,
279 281 ),
280 282 clock: (
281 283 Res<Time<Virtual>>,
@@ -402,7 +404,7 @@ fn update_tile_info(
402 404 .join(" ")
403 405 };
404 406 let materials = format!("{}\n{}", stock_line(false), stock_line(true));
405 - let (tool, build_tool, designate_tool, rooms, _) = tools;
407 + let (tool, build_tool, designate_tool, rooms, _, traders) = tools;
406 408 let hovered_room = cursor
407 409 .tile
408 410 .and_then(|tile| rooms.at(tile))
@@ -448,6 +450,15 @@ fn update_tile_info(
448 450 )
449 451 }),
450 452 )
453 + .chain(
454 + traders
455 + .iter()
456 + .filter(|(_, pos)| Some(pos.0) == cursor.tile)
457 + .map(|(trader, _)| match trader.open() {
458 + true => format!(", trader selling {}", describe_wares(&trader.stock)),
459 + false => ", trader".to_string(),
460 + }),
461 + )
451 462 .collect();
452 463 let events: String = log.0.iter().map(|line| format!("\n{line}")).collect();
453 464 let tool_hint = match (tool.mode, build_tool.mode) {
@@ -591,6 +602,7 @@ pub fn progress_fraction(task: Task) -> Option<f32> {
591 602 Task::Butcher { progress, .. } => Some(progress as f32 / BUTCHER_WORK as f32),
592 603 Task::Tame { kind, progress, .. } => Some(progress as f32 / kind.tame_work() as f32),
593 604 Task::Tend { progress, .. } => Some(progress as f32 / TEND_WORK as f32),
605 + Task::Trade { progress, .. } => Some(progress as f32 / TRADE_WORK as f32),
594 606 _ => None,
595 607 }
596 608 }
@@ -708,6 +720,18 @@ pub fn describe_colonist(view: &ColonistView) -> String {
708 720 (Some(Task::Equip { .. }), _) => "fetching tools".to_string(),
709 721 (Some(Task::Fight { .. }), _) => "fighting a raider".to_string(),
710 722 (
723 + Some(Task::Trade {
724 + pickup: Some(_),
725 + give,
726 + ..
727 + }),
728 + _,
729 + ) => format!("fetching {give:?} to trade"),
730 + (Some(Task::Trade { want, .. }), n) if n > 0 => format!("going to trade for {want:?}"),
731 + (Some(task @ Task::Trade { want, .. }), _) => {
732 + format!("trading for {want:?}, {}%", percent(task))
733 + }
734 + (
711 735 Some(Task::Tend {
712 736 pickup: Some(_), ..
713 737 }),

src/trading.rs +430 -0

@@ -0,0 +1,430 @@
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 +}

tasks.md +1 -0

@@ -60,6 +60,7 @@
60 60 - [x] Colonist panel for big colonies: households as collapsible, flagged rows in a scrollable list
61 61 - [x] Raiders: periodic bands that steal from stores and fight colonists; colonists defend (ranged, Hunting skill), raiders flee when hurt or loaded, killed raiders drop loot
62 62 - [x] Social life: personalities (warmth), shared opinions from chats/arguments near each other, friendships/rivalries (with hysteresis) affect mood and loneliness, rivals never pair up, friends visit when idle and mourn the dead; saved
63 +- [x] Trading: caravans visit every few days with random wares, colonists haul surplus (above 1.5× goal) to them for goods below goal (trader takes a 20% margin); stock targets steer what is bought and sold
63 64 - [x] Action/keybind system, toolbar buttons for every keybind, hide-UI toggle (H), clicks on UI don't reach the map
64 65
65 66 ## Planned