Add trading caravans
- 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 | |
| src/jobs/mod.rs | +121 -6 | |
| src/jobs/tests.rs | +26 -0 | |
| src/main.rs | +2 -0 | |
| src/save.rs | +1 -0 | |
| src/selection.rs | +25 -1 | |
| src/trading.rs | added | +430 -0 |
| tasks.md | +1 -0 |
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 |