From bdf819f721265416cc071a97e4c8e07b0af297f8 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Mon, 12 Oct 2026 20:10:22 +0200 Subject: [PATCH] feat: jobs, markets, haggling, carts and merchants Each shard founds markets and workplaces (farms, woodcutters, quarries, hunters, toolmakers, cartwrights). Adults are hired, commute to work, sell their goods at the market, get paid and go home. Markets, workplaces and carts are actors; prices follow local supply and demand and trades are haggled. Merchants trade with markets on other shards through the router, so they do not know whether a market is remote. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 + config.rn | 55 +- src/world.rs | 732 ++++++++++++++++++++++++-- src/world/actors.rs | 79 ++- src/world/economy.rs | 843 ++++++++++++++++++++++++++++++ src/world/router.rs | 107 +++- src/world/work.rs | 1182 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 2956 insertions(+), 48 deletions(-) create mode 100644 src/world/economy.rs create mode 100644 src/world/work.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb9dff..6aa6fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Notable changes to this project are documented here. - Expose the Picard actor console over the `/api/actors/ws` WebSocket endpoint. - The world can be split into shards that each own a strip of columns. People and animals that walk across a shard border are transferred with their inventory and stay reachable at the same address. +- Jobs and trading: every shard founds markets, farms, woodcutters, quarries, hunters, toolmakers and cartwrights. Adults are hired, commute to their job site each day, work their shift, sell the goods at the market, get paid and go home. +- Markets are actors with their own coins and stock; prices follow local supply and demand, and buyers and sellers haggle. +- Farmers sow fields that ripen into crops; gatherers fell trees, quarry stone and hunt; crafters buy materials and make tools and carts. +- Carts are actors that add carrying capacity, wear out and can be bought at the market. +- Merchants carry their market's surplus to markets on other shards and bring back bargains. +- People earn and spend coins; workers buy tools, meals and food for the village. ### Changed diff --git a/config.rn b/config.rn index ed81dc2..75ad4f1 100644 --- a/config.rn +++ b/config.rn @@ -150,6 +150,50 @@ pub const MAX_HUNGER_EDIT = 99; pub const MAX_ANIMAL_ENERGY_EDIT = 100.0; pub const MAX_RESOURCE_EDIT = 50; +// Economy: the share of each day people work, and how much they carry +pub const WORK_START = 0.3; +pub const WORK_END = 0.65; +pub const CARRY_CAPACITY = 8; +pub const CART_CAPACITY = 24; +pub const CART_DURABILITY = 3000; +pub const CART_WOOD_COST = 8; + +// Economy: money, households and employment +pub const INITIAL_PERSON_COINS = 20; +pub const INITIAL_WORKPLACE_COINS = 150; +pub const INITIAL_MARKET_COINS = 600; +pub const QUIT_AFTER_UNPAID_DAYS = 3; +// Workers top up the village food stock to this many units per resident. +pub const HOUSEHOLD_FOOD_TARGET = 25; +pub const HOUSEHOLD_COIN_RESERVE = 10; +pub const CART_PURCHASE_COINS = 80; + +// Economy: work sites, farming and trade +pub const WORK_RADIUS = 8; +pub const FIELD_RADIUS = 2; +pub const CROP_RIPE = 4; +pub const CROP_YIELD = 4; +pub const CRAFT_BUDGET = 40; +pub const MERCHANT_ESCROW = 60; +pub const HAGGLE_ROUNDS = 4; + +// Goods: base price, and the stock at which a market charges the base price +pub const PRICE_FOOD = #{ price: 4, target: 60 }; +pub const PRICE_WOOD = #{ price: 2, target: 40 }; +pub const PRICE_STONE = #{ price: 3, target: 30 }; +pub const PRICE_TOOLS = #{ price: 14, target: 8 }; +pub const PRICE_CARTS = #{ price: 40, target: 2 }; + +// Trades: sites per shard, workers per site and the wage per shift. +// TRADE_MERCHANT.count is the number of markets per shard (0 disables the economy). +pub const TRADE_FARM = #{ count: 2, workers: 3, wage: 6 }; +pub const TRADE_WOODCUTTER = #{ count: 1, workers: 3, wage: 5 }; +pub const TRADE_QUARRY = #{ count: 1, workers: 2, wage: 5 }; +pub const TRADE_HUNTER = #{ count: 1, workers: 2, wage: 5 }; +pub const TRADE_TOOLMAKER = #{ count: 1, workers: 1, wage: 7 }; +pub const TRADE_CARTWRIGHT = #{ count: 1, workers: 1, wage: 7 }; +pub const TRADE_MERCHANT = #{ count: 1, workers: 2, wage: 8 }; + // Behaviour tuning (used only by the behaviour functions below) pub const HUNGER_SCRIPT_THRESHOLD = 50.0; pub const PERSON_CARRY_LIMIT = 6; @@ -163,14 +207,23 @@ pub const RABBIT_BEHAVIOR_WANDER_CHANCE = 0.5; // Behaviours. Each function receives what the entity senses and returns the // name of an action; the Rust side carries it out. -// person: sleep | go_home | eat | haul | hunt | forage | chop | mine | wander +// person: work | rest | sleep | go_home | eat | haul | hunt | forage | chop | mine | wander pub fn person(c) { + if c.on_trip { + return "work"; + } if c.night { return if c.at_home { "sleep" } else { "go_home" }; } if c.hunger > HUNGER_SCRIPT_THRESHOLD && c.carried_food == 0 && c.stock_food > 0 { return "eat"; } + if c.employed && (c.on_shift || (c.work_time && !c.shift_done)) { + return "work"; + } + if c.employed && c.shift_done { + return if c.at_home { "rest" } else { "go_home" }; + } if c.carry >= PERSON_CARRY_LIMIT { return "haul"; } diff --git a/src/world.rs b/src/world.rs index fa89ac2..0fd7d0b 100644 --- a/src/world.rs +++ b/src/world.rs @@ -1,14 +1,17 @@ mod actors; +mod economy; #[allow( dead_code, reason = "the cluster gateway and shard nodes use the remote side" )] mod router; +mod work; use actors::{ Activity, AnimalId, AnimalMsg, AnimalTurn, Area, BirthCost, Goods, Mailbox, Needs, PartnershipMsg, PersonEdit, PersonMsg, ReplyBody, RequestId, }; +use economy::{Cart, CartMsg, Job, Market, MarketMsg, Quote, Workplace, WorkplaceMsg}; use picard::actors::{ActorRef, ActorSystem, Sender, Tell}; use router::{Arrival, LocalActor}; pub use router::{EntityKey, Kind, Router, Shard}; @@ -50,6 +53,21 @@ pub struct CalendarSettings { pub elderly: StageDuration, } +/// Base price and the stock a market aims to hold of one good. +#[derive(Clone, Copy, Debug, rune::FromValue)] +pub struct GoodSettings { + pub price: u32, + pub target: u32, +} + +/// How many workplaces of one trade each shard founds, their positions and the daily wage. +#[derive(Clone, Copy, Debug, rune::FromValue)] +pub struct TradeSettings { + pub count: usize, + pub workers: usize, + pub wage: u32, +} + impl Default for StageDuration { fn default() -> Self { Self { @@ -171,6 +189,38 @@ pub struct SimulationConfig { max_hunger_edit: i64, max_animal_energy_edit: f32, max_resource_edit: u16, + work_start: f32, + work_end: f32, + carry_capacity: u32, + cart_capacity: u32, + cart_durability: u32, + cart_wood_cost: u32, + initial_person_coins: u32, + initial_workplace_coins: u32, + initial_market_coins: u32, + quit_after_unpaid_days: u32, + household_food_target: u32, + household_coin_reserve: u32, + cart_purchase_coins: u32, + work_radius: usize, + field_radius: usize, + crop_ripe: u16, + crop_yield: u32, + craft_budget: u32, + merchant_escrow: u32, + haggle_rounds: u32, + price_food: GoodSettings, + price_wood: GoodSettings, + price_stone: GoodSettings, + price_tools: GoodSettings, + price_carts: GoodSettings, + trade_farm: TradeSettings, + trade_woodcutter: TradeSettings, + trade_quarry: TradeSettings, + trade_hunter: TradeSettings, + trade_toolmaker: TradeSettings, + trade_cartwright: TradeSettings, + trade_merchant: TradeSettings, } impl SimulationConfig { @@ -179,7 +229,80 @@ impl SimulationConfig { self.validate_calendar()?; self.validate_chances()?; self.validate_world()?; - self.validate_amounts() + self.validate_amounts()?; + self.validate_economy() + } + + fn validate_economy(&self) -> Result<(), String> { + if !(0.0..1.0).contains(&self.work_start) + || !(self.work_start..=1.0).contains(&self.work_end) + || self.work_start >= self.work_end + { + return Err("work_start and work_end must satisfy 0 <= start < end <= 1".into()); + } + if self.carry_capacity == 0 + || self.carry_capacity > 10_000 + || self.cart_capacity > 10_000 + || self.cart_durability == 0 + || self.cart_wood_cost == 0 + || self.crop_ripe == 0 + || !(1..=100).contains(&self.haggle_rounds) + || self.work_radius > 64 + || self.field_radius > 8 + { + return Err("economy capacities, costs and radii are outside their ranges".into()); + } + for (name, value) in [ + ("initial_person_coins", self.initial_person_coins), + ("initial_workplace_coins", self.initial_workplace_coins), + ("initial_market_coins", self.initial_market_coins), + ("household_food_target", self.household_food_target), + ("household_coin_reserve", self.household_coin_reserve), + ("cart_purchase_coins", self.cart_purchase_coins), + ("crop_yield", self.crop_yield), + ("craft_budget", self.craft_budget), + ("merchant_escrow", self.merchant_escrow), + ] { + if value > 1_000_000 { + return Err(format!("{name} must be at most 1000000")); + } + } + for (name, good) in [ + ("price_food", self.price_food), + ("price_wood", self.price_wood), + ("price_stone", self.price_stone), + ("price_tools", self.price_tools), + ("price_carts", self.price_carts), + ] { + if !(1..=100_000).contains(&good.price) || !(1..=100_000).contains(&good.target) { + return Err(format!( + "{name} needs a price and target between 1 and 100000" + )); + } + } + for (name, trade) in self.trades() { + if trade.count > 16 || trade.workers > 100 || trade.wage > 100_000 { + return Err(format!( + "trade_{name} allows at most 16 sites, 100 workers and a wage of 100000" + )); + } + } + if self.trade_merchant.count > 1 { + return Err("trade_merchant.count is the number of markets per shard: 0 or 1".into()); + } + Ok(()) + } + + fn trades(&self) -> [(&'static str, TradeSettings); 7] { + [ + ("farm", self.trade_farm), + ("woodcutter", self.trade_woodcutter), + ("quarry", self.trade_quarry), + ("hunter", self.trade_hunter), + ("toolmaker", self.trade_toolmaker), + ("cartwright", self.trade_cartwright), + ("merchant", self.trade_merchant), + ] } fn validate_limits(&self) -> Result<(), String> { @@ -502,6 +625,38 @@ const CONFIG_FIELDS: &[&str] = &[ "max_hunger_edit", "max_animal_energy_edit", "max_resource_edit", + "work_start", + "work_end", + "carry_capacity", + "cart_capacity", + "cart_durability", + "cart_wood_cost", + "initial_person_coins", + "initial_workplace_coins", + "initial_market_coins", + "quit_after_unpaid_days", + "household_food_target", + "household_coin_reserve", + "cart_purchase_coins", + "work_radius", + "field_radius", + "crop_ripe", + "crop_yield", + "craft_budget", + "merchant_escrow", + "haggle_rounds", + "price_food", + "price_wood", + "price_stone", + "price_tools", + "price_carts", + "trade_farm", + "trade_woodcutter", + "trade_quarry", + "trade_hunter", + "trade_toolmaker", + "trade_cartwright", + "trade_merchant", ]; const CONFIG_ACCESSOR: &str = "__sim_config"; @@ -710,6 +865,8 @@ const MOUNTAIN: u8 = 6; const TREE: u8 = 1; const BERRY: u8 = 2; const STONE: u8 = 3; +/// A sown field tile; its amount is how far the crop has grown. +const CROP: u8 = 4; pub struct Rng(u64); impl Rng { @@ -853,6 +1010,22 @@ struct Ctx { prey_near: bool, #[rune(get)] roll: f64, + #[rune(get)] + employed: bool, + /// Whether it is working hours. + #[rune(get)] + work_time: bool, + /// Whether the person's shift for today is over. + #[rune(get)] + shift_done: bool, + /// Whether the person is in the middle of a shift and should finish it. + #[rune(get)] + on_shift: bool, + /// Whether the person is a merchant on a trading trip. + #[rune(get)] + on_trip: bool, + #[rune(get)] + coins: i64, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -879,6 +1052,21 @@ pub struct Person { /// State of the person's own random stream, used for their life-stage durations. #[serde(default)] rng: u64, + #[serde(default)] + tools: u32, + #[serde(default)] + coins: u32, + /// The village the person lives in; `None` in snapshots from before people had homes. + #[serde(default)] + home: Option<(usize, usize)>, + #[serde(default)] + job: Option, + /// The cart the person owns. + #[serde(default)] + cart: Option, + /// Shifts in a row the employer could not pay in full. + #[serde(default)] + unpaid_days: u32, } fn default_stage_duration_days() -> u32 { @@ -887,7 +1075,12 @@ fn default_stage_duration_days() -> u32 { impl Person { fn carry(&self) -> u32 { - self.food + self.wood + self.stone + self.food + self.wood + self.stone + self.tools + } + + /// Everything the person carries, their own goods and their employer's. + fn load(&self) -> u32 { + self.carry() + self.job.as_ref().map_or(0, |job| job.goods.total()) } fn position(&self) -> (usize, usize) { @@ -972,6 +1165,7 @@ struct Census { people: Vec, rabbits: Vec<(AnimalId, Animal)>, wolves: Vec<(AnimalId, Animal)>, + carts: Vec, } impl Census { @@ -997,6 +1191,14 @@ pub struct World { rabbit_actors: BTreeMap>, wolf_actors: BTreeMap>, partnership_actors: BTreeMap>, + workplace_actors: BTreeMap>, + market_actors: BTreeMap>, + cart_actors: BTreeMap>, + /// Field tiles of the farms on this shard. + fields: HashSet, + /// The latest prices of every market in the cluster. + board: Vec, + /// Ids of workplaces, markets and carts share the animal id sequence. next_animal_id: AnimalId, next_partnership_id: u32, mailbox: Mailbox, @@ -1061,6 +1263,12 @@ struct WorldSnapshot { next_animal_id: Option, #[serde(default)] shard: Option, + #[serde(default)] + workplaces: Vec, + #[serde(default)] + markets: Vec, + #[serde(default)] + carts: Vec, } fn default_next_partnership_id() -> u32 { @@ -1201,6 +1409,7 @@ impl World { LifeStage::Adult }; self.make_grown_up(&mut person, stage); + person.coins = self.config.initial_person_coins; self.spawn_person_actor(person); } for _ in 0..self.shard.share(self.config.initial_rabbits) { @@ -1266,6 +1475,11 @@ impl World { rabbit_actors: BTreeMap::new(), wolf_actors: BTreeMap::new(), partnership_actors: BTreeMap::new(), + workplace_actors: BTreeMap::new(), + market_actors: BTreeMap::new(), + cart_actors: BTreeMap::new(), + fields: HashSet::new(), + board: Vec::new(), next_animal_id: first_id, next_partnership_id: u32::try_from(first_id).expect("shard counts are small"), mailbox: Mailbox::new(&actor_system), @@ -1336,6 +1550,9 @@ impl World { w.rng.0 ^= (shard.index as u64).wrapping_mul(0xD1B5_4A32_D192_ED03); w.rng.0 |= 1; w.populate(); + w.found_economy(); + let people = w.observe_people(); + w.employ(&people); w } @@ -1486,6 +1703,10 @@ impl World { self.animal_actors_mut(species).insert(id, actor); EntityKey::new(kind, id) } + Arrival::Cart(id, actor) => { + self.cart_actors.insert(id, actor); + EntityKey::new(Kind::Cart, id) + } }; self.departed.retain(|departure| departure.key != key); self.report_arrival(key); @@ -1545,6 +1766,15 @@ impl World { } } } + for cart in &census.carts { + if !self.owns(cart.position()) + && let Some(actor) = self.cart_actors.remove(&cart.id) + { + let shard = self.shard.owner(cart.x, self.width); + actor.tell(CartMsg::Depart(request, shard), self.mailbox.reply_to()); + leaving.push((cart.key(), shard, LocalActor::Cart(actor))); + } + } self.mailbox.collect(request, leaving.len()); for (key, shard, actor) in leaving { self.report.moved.push((key, shard)); @@ -1617,6 +1847,7 @@ impl World { people: self.observe_people(), rabbits: self.observe_animals(Species::Rabbit), wolves: self.observe_animals(Species::Wolf), + carts: self.observe_carts(), } } @@ -1679,6 +1910,12 @@ impl World { stage_duration_days, partner_id: None, rng: self.rng.next() | 1, + tools: 0, + coins: 0, + home: Some(self.home), + job: None, + cart: None, + unpaid_days: 0, } } @@ -1881,22 +2118,31 @@ impl World { self.receive_arrivals(); self.retire_departures(); self.regrow(); + let new_day = self.tick.is_multiple_of(self.calendar.ticks_per_day); + if new_day { + self.grow_crops(); + } let mut census = Census { people: self.advance_people(), rabbits: self.observe_animals(Species::Rabbit), wolves: self.observe_animals(Species::Wolf), + carts: self.observe_carts(), }; if self.tick.is_multiple_of(self.config.entity_update_interval) { self.tick_people(&mut census); + self.tick_carts(&mut census); self.tick_rabbits(&mut census); } else { self.tick_wolves(&mut census); } - let new_day = self.tick.is_multiple_of(self.calendar.ticks_per_day); if self.retire_dead(&mut census) || (self.shard.count > 1 && new_day) { self.prune_partnerships(&mut census.people); } self.population(&mut census); + if new_day { + self.employ(&census.people); + self.refresh_board(); + } self.transfer_out(&census); } @@ -1936,6 +2182,9 @@ impl World { .filter(|person| person.dead) .map(|person| person.id) .collect(); + for person in census.people.iter().filter(|person| person.dead) { + self.leave_job(person); + } for &id in &dead_people { self.retire_person(id); } @@ -1986,7 +2235,7 @@ impl World { for _ in 0..self.config.resource_regrow_attempts { let tile = self.rng.below(columns.len() * self.height); let i = (tile / columns.len()) * self.width + columns.start + tile % columns.len(); - if self.res[i].0 != 0 { + if self.res[i].0 != 0 || self.fields.contains(&i) { continue; } let p = self.rng.f(); @@ -2189,7 +2438,7 @@ impl World { /// Lets a person get hungry and eat, settling deposits and meals with the stockpile. fn meet_needs(&mut self, person: &Person) -> Option { let actor = self.person_actors.get(&person.id)?; - let at_home = cheb(person.position(), self.home) <= self.config.home_radius; + let at_home = self.at_home(person); let night = self.daylight() < self.config.daylight_night_threshold; let needs = Needs { at_home, @@ -2221,23 +2470,26 @@ impl World { Some(person) } - /// Harvests `kind` at `target` when standing on it, otherwise walks there. + /// Harvests up to `room` of `kind` at `target` when standing on it, otherwise walks there. fn gather( &mut self, - pos: (usize, usize), + person: &Person, kind: u8, target: (usize, usize), + room: u32, ) -> ((usize, usize), Goods) { + let pos = person.position(); let idx = pos.1 * self.width + pos.0; if pos != target || self.res[idx].0 != kind { return (self.move_toward(pos, target), Goods::default()); } - let yield_n: u16 = if self.tools >= self.population_u32() { + let yield_n: u16 = if person.tools > 0 || self.tools >= self.population_u32() { self.config.person_tooled_gather_yield } else { self.config.person_gather_yield }; - let take = yield_n.min(self.res[idx].1); + let room = u16::try_from(room).unwrap_or(u16::MAX); + let take = yield_n.min(self.res[idx].1).min(room); self.res[idx].1 -= take; if self.res[idx].1 == 0 { self.res[idx].0 = 0; @@ -2278,14 +2530,12 @@ impl World { fn choose_activity(&mut self, p: &Person, census: &mut Census) -> Activity { let night = self.daylight() < self.config.daylight_night_threshold; let pos = p.position(); - let home = self.home; - let at_home = cheb(pos, home) <= self.config.home_radius; - let activity = |action: &str, position| Activity { - action: action.to_string(), - position, - gathered: Goods::default(), - }; - if p.hunger > self.config.hunger_return_threshold && self.food > 0 && !at_home { + let home = self.home_of(p); + let at_home = self.at_home(p); + let activity = |action: &str, position| Activity::new(action, position, Goods::default()); + let on_trip = p.job.as_ref().is_some_and(Job::on_trip); + let food_at_home = self.food > 0 || !self.owns(home); + if p.hunger > self.config.hunger_return_threshold && food_at_home && !at_home && !on_trip { return activity("returning for food", self.move_toward(pos, home)); } if !p.stage.can_partner() { @@ -2315,6 +2565,15 @@ impl World { stone_near: stone.is_some(), rabbit_near: rabbit.is_some(), roll: f64::from(self.rng.f()), + employed: p.job.is_some(), + work_time: self.work_time(), + shift_done: p + .job + .as_ref() + .is_some_and(|job| job.shift_done(self.today())), + on_shift: p.job.as_ref().is_some_and(Job::on_shift), + on_trip, + coins: i64::from(p.coins), ..Default::default() }; let choice = self.decide("person", ctx); @@ -2325,6 +2584,8 @@ impl World { _ => None, }; match (choice.as_str(), gather, rabbit) { + ("work", ..) if p.job.is_some() => self.work(p, census), + ("rest", ..) => activity("resting", pos), ("sleep", ..) => activity("sleeping", pos), ("go_home" | "haul" | "eat", ..) => { let action = match choice.as_str() { @@ -2353,12 +2614,9 @@ impl World { hunting } (_, Some((kind, Some(target), label)), _) => { - let (position, gathered) = self.gather(pos, kind, target); - Activity { - action: label.to_string(), - position, - gathered, - } + let room = self.capacity(p).saturating_sub(p.load()); + let (position, gathered) = self.gather(p, kind, target, room); + Activity::new(label, position, gathered) } _ => activity("wandering", self.wander(pos.0, pos.1)), } @@ -2646,6 +2904,9 @@ impl World { next_partnership_id: self.next_partnership_id, next_animal_id: Some(self.next_animal_id), shard: Some(self.shard), + workplaces: self.observe_workplaces(), + markets: self.observe_markets(), + carts: census.carts, }) } @@ -2772,6 +3033,20 @@ impl World { for partnership in partnerships { world.spawn_partnership_actor(partnership); } + if saved.markets.is_empty() && saved.workplaces.is_empty() { + world.found_economy(); + } else { + for market in saved.markets { + world.spawn_market_actor(market); + } + for workplace in saved.workplaces { + world.spawn_workplace_actor(workplace); + } + for cart in saved.carts { + world.spawn_cart_actor(cart); + } + world.refresh_board(); + } Ok(world) } @@ -2880,6 +3155,7 @@ impl World { "food" => PersonEdit::Food(u), "wood" => PersonEdit::Wood(u), "stone" => PersonEdit::Stone(u), + "coins" => PersonEdit::Coins(u), _ => return, }; self.tell_person(id, PersonMsg::Edit(edit)); @@ -3103,7 +3379,7 @@ impl World { .iter() .map(|p| { format!( - "[{},{},{},\"{}\",{:.0},{},{},{},\"{}\",{},{}]", + "[{},{},{},\"{}\",{:.0},{},{},{},\"{}\",{},{},{},{}]", p.id, p.x, p.y, @@ -3115,7 +3391,12 @@ impl World { p.stage.key(), p.age_ticks / self.calendar.ticks_per_day, p.partner_id - .map_or_else(|| "null".to_string(), |partner| partner.to_string()) + .map_or_else(|| "null".to_string(), |partner| partner.to_string()), + p.coins, + p.job.as_ref().map_or_else( + || "null".to_string(), + |job| format!("\"{}\"", job.trade.key()) + ) ) }) .collect(); @@ -3152,6 +3433,7 @@ impl World { ); } } + self.write_economy_json(s); s.push_str("],\"log\":["); let j: Vec = self.log.iter().map(|l| format!("\"{l}\"")).collect(); s.push_str(&j.join(",")); @@ -3232,6 +3514,32 @@ impl World { &self.router } + /// Prices of this shard's markets, for the gateway to publish to every shard. + pub fn quotes(&self) -> Vec { + let local: HashSet = self + .market_actors + .keys() + .map(|&id| EntityKey::new(Kind::Market, id)) + .collect(); + self.board + .iter() + .filter(|quote| local.contains("e.market)) + .cloned() + .collect() + } + + /// Replaces the prices of other shards' markets with the ones the gateway published. + pub fn set_board(&mut self, quotes: Vec) { + let local: HashSet = self + .market_actors + .keys() + .map(|&id| EntityKey::new(Kind::Market, id)) + .collect(); + self.board.retain(|quote| local.contains("e.market)); + self.board + .extend(quotes.into_iter().filter(|q| !local.contains(&q.market))); + } + /// Returns and resets what changed since the last report. pub fn take_report(&mut self) -> StepReport { std::mem::take(&mut self.report) @@ -3248,7 +3556,25 @@ impl World { .wolf_actors .keys() .map(|&id| EntityKey::new(Kind::Wolf, id)); - people.chain(rabbits).chain(wolves).collect() + let workplaces = self + .workplace_actors + .keys() + .map(|&id| EntityKey::new(Kind::Workplace, id)); + let markets = self + .market_actors + .keys() + .map(|&id| EntityKey::new(Kind::Market, id)); + let carts = self + .cart_actors + .keys() + .map(|&id| EntityKey::new(Kind::Cart, id)); + people + .chain(rabbits) + .chain(wolves) + .chain(workplaces) + .chain(markets) + .chain(carts) + .collect() } } @@ -3262,9 +3588,11 @@ impl Drop for World { #[cfg(test)] mod tests { use super::actors::{Activity, AnimalTurn, Goods, PersonEdit, PersonMsg, ReplyBody}; + use super::economy::{Cart, Good, Job, MarketMsg, Phase, Trade, Workplace}; + use super::router::Kind; use super::{ - Animal, DEFAULT_SCRIPT, EntityKey, FORWARDING_TICKS, H, LifeStage, Router, Shard, - SimScript, W, World, cheb, default_config, + Animal, CROP, DEFAULT_SCRIPT, EntityKey, FORWARDING_TICKS, H, LifeStage, Person, Router, + Shard, SimScript, W, World, cheb, default_config, }; use crate::world::router::RemoteSender; use picard::remoting::{ActorAddress, RemoteRequest}; @@ -3382,6 +3710,342 @@ mod tests { SimScript::load(&source).unwrap() } + fn economy_script() -> SimScript { + let source = [ + ("WIDTH", "96"), + ("HEIGHT", "64"), + ("TICKS_PER_DAY", "240"), + ("INITIAL_POPULATION", "30"), + ("INITIAL_YOUNG_ADULTS", "30"), + ("INITIAL_RABBITS", "200"), + ("INITIAL_FOOD", "600"), + ] + .into_iter() + .fold(DEFAULT_SCRIPT.to_string(), |source, (name, value)| { + with_constant(&source, name, value) + }); + SimScript::load(&source).unwrap() + } + + fn wait_until(mut done: impl FnMut() -> bool, what: &str) { + let deadline = Instant::now() + Duration::from_secs(5); + while !done() { + assert!(Instant::now() < deadline, "{what} never happened"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + /// Moves the clock to the middle of the working hours of the current day. + fn set_work_time(world: &mut World) { + let day = world.calendar.ticks_per_day; + world.tick = world.tick / day * day + day * 45 / 100; + assert!(world.work_time()); + } + + /// A grown-up of `world` employed at `employer`, standing at `position` in `phase`. + fn worker( + world: &World, + employer: EntityKey, + phase: Phase, + position: (usize, usize), + ) -> Person { + let markets = world.observe_markets(); + let (trade, site, market) = match employer.kind { + Kind::Market => { + let market = markets.iter().find(|m| m.key() == employer).unwrap(); + (Trade::Merchant, market.site(), market.clone()) + } + _ => { + let workplace = world + .observe_workplaces() + .into_iter() + .find(|w| w.key() == employer) + .unwrap(); + let market = markets + .iter() + .find(|m| m.key() == workplace.market) + .unwrap(); + (workplace.trade, workplace.site(), market.clone()) + } + }; + let mut person = world + .observe_people() + .into_iter() + .find(|person| person.stage.can_partner() && person.job.is_none()) + .unwrap_or_else(|| world.observe_people()[0].clone()); + let mut job = Job::new(employer, trade, site, &market); + job.phase = phase; + person.job = Some(job); + (person.x, person.y) = position; + person.hunger = 0.0; + person + } + + fn workplace_of(world: &World, trade: Trade) -> Workplace { + world + .observe_workplaces() + .into_iter() + .find(|workplace| workplace.trade == trade) + .unwrap() + } + + #[test] + fn every_shard_founds_markets_and_workplaces_and_hires_staff() { + let world = World::new_with_script(42, economy_script()); + let markets = world.observe_markets(); + let workplaces = world.observe_workplaces(); + assert_eq!(markets.len(), world.config.trade_merchant.count); + for trade in Trade::WORKSHOPS { + assert!( + workplaces.iter().any(|w| w.trade == trade), + "no {} was founded", + trade.key() + ); + } + let farm = workplace_of(&world, Trade::Farm); + assert!(!farm.fields.is_empty()); + for &(x, y) in &farm.fields { + assert_eq!(world.res[y * world.width + x].0, CROP); + } + let employed = world + .observe_people() + .iter() + .filter(|person| person.job.is_some()) + .count(); + let staffed: usize = workplaces + .iter() + .map(|w| w.staff.employees.len()) + .chain(markets.iter().map(|m| m.staff.employees.len())) + .sum(); + assert!(employed > 0); + assert_eq!(employed, staffed); + } + + #[test] + fn workers_finish_their_shifts_sell_goods_and_get_paid() { + let mut world = World::new_with_script(42, economy_script()); + let start: u32 = world.observe_people().iter().map(|p| p.coins).sum(); + let days = 3; + while world.tick < world.calendar.ticks_per_day * days { + world.step(); + } + let people = world.observe_people(); + assert!( + people + .iter() + .filter_map(|person| person.job.as_ref()) + .any(|job| job.day.is_some()), + "nobody finished a shift: {:?}", + people + .iter() + .filter(|p| p.job.is_some()) + .map(|p| (p.id, p.action.as_str(), p.job.as_ref().unwrap().phase)) + .collect::>() + ); + let stock: u32 = world + .observe_markets() + .iter() + .map(|market| market.stock.total()) + .sum(); + assert!(stock > 0, "no goods reached the markets"); + let earned: u32 = people.iter().map(|p| p.coins).sum(); + assert_ne!(earned, start, "nobody earned or spent anything"); + } + + #[test] + fn farmers_harvest_ripe_crops_and_sow_empty_fields() { + let mut world = World::new_with_script(42, economy_script()); + set_work_time(&mut world); + let farm = workplace_of(&world, Trade::Farm); + for &(x, y) in &farm.fields { + world.res[y * world.width + x] = (CROP, 1); + } + let field = farm.fields[0]; + let index = field.1 * world.width + field.0; + world.res[index] = (CROP, world.config.crop_ripe); + let mut farmer = worker(&world, farm.key(), Phase::Work, field); + let mut census = world.census(); + + let harvest = world.work(&farmer, &mut census); + assert_eq!(harvest.action, "harvesting"); + assert_eq!(world.res[index], (0, 0)); + let job = harvest.job.unwrap(); + assert_eq!(job.goods.food, world.config.crop_yield); + assert_eq!(farmer.food, 0, "the harvest belongs to the farm"); + + farmer.job = Some(job); + let sow = world.work(&farmer, &mut census); + assert_eq!(sow.action, "sowing"); + assert_eq!(world.res[index], (CROP, 1)); + + world.grow_crops(); + assert_eq!(world.res[index], (CROP, 2)); + world.regrow(); + assert_eq!(world.res[index].0, CROP, "fields don't grow wild"); + } + + #[test] + fn cartwrights_build_carts_that_others_buy_for_more_capacity() { + let mut world = World::new_with_script(42, economy_script()); + set_work_time(&mut world); + let shop = workplace_of(&world, Trade::Cartwright); + let mut cartwright = worker(&world, shop.key(), Phase::Craft, shop.site()); + cartwright.job.as_mut().unwrap().goods.wood = world.config.cart_wood_cost; + let mut census = world.census(); + + let built = world.work(&cartwright, &mut census); + assert_eq!(built.action, "building a cart"); + let mut job = built.job.unwrap(); + let [cart] = job.wares[..] else { + panic!("no cart was built: {job:?}"); + }; + assert_eq!(job.goods.wood, 0); + let carts = world.observe_carts(); + assert_eq!(carts.len(), 1); + assert_eq!(carts[0].owner, Some(cartwright.id)); + + job.phase = Phase::Sell; + cartwright.job = Some(job); + (cartwright.x, cartwright.y) = shop.market_site; + let sold = world.work(&cartwright, &mut census); + assert!(sold.job.unwrap().wares.is_empty()); + let market = world.market_actors[&shop.market.id].clone(); + wait_until( + || { + world.observe_carts()[0].owner.is_none() + && matches!( + world.ask(&market, MarketMsg::Observe), + ReplyBody::Market(m) if m.carts == vec![cart] + ) + }, + "the cart sale", + ); + + let mut buyer = worker(&world, shop.key(), Phase::Sell, shop.market_site); + buyer.id = world + .observe_people() + .iter() + .map(|p| p.id) + .find(|&id| id != cartwright.id) + .unwrap(); + buyer.coins = 200; + buyer.tools = 1; + world.food = world.household_food_target(); + let bought = world.work(&buyer, &mut census); + assert_eq!(bought.cart, Some(cart)); + assert!(bought.spent > 0); + wait_until( + || world.observe_carts()[0].owner == Some(buyer.id), + "the cart purchase", + ); + let carrying = world.capacity(&buyer); + buyer.cart = bought.cart; + assert_eq!( + world.capacity(&buyer), + carrying + world.config.cart_capacity + ); + } + + #[test] + fn merchants_trade_at_markets_on_other_shards() { + let mut shards = connected_shards(2); + publish_directory(&mut shards); + let home_market = shards[0].observe_markets()[0].clone(); + let far_market = shards[1].observe_markets()[0].clone(); + // Food is scarce far away and plentiful at home. + let mut quotes = shards[0].quotes(); + quotes.extend(shards[1].quotes()); + for world in &mut shards { + world.set_board(quotes.clone()); + for quote in &mut world.board { + if quote.market == home_market.key() { + quote.prices[Good::Food.index()] = 1; + } + } + } + assert_eq!(shards[0].board.len(), 2); + assert_eq!(shards[0].quotes().len(), 1); + + let mut merchant = worker( + &shards[0], + home_market.key(), + Phase::Commute, + home_market.site(), + ); + merchant.job.as_mut().unwrap().goods.food = 6; + let mut census = shards[0].census(); + let setting_out = shards[0].work(&merchant, &mut census); + let job = setting_out.job.unwrap(); + assert_eq!(job.phase, Phase::Travel); + assert_eq!(job.destination, Some((far_market.key(), far_market.site()))); + + // At the far market the merchant is on the other shard, which drives them now. + merchant.job = Some(job); + (merchant.x, merchant.y) = far_market.site(); + let mut census = shards[1].census(); + let traded = shards[1].work(&merchant, &mut census); + let job = traded.job.unwrap(); + assert_eq!(job.phase, Phase::Return); + assert_eq!(job.goods.food, 0); + assert!(job.coins > 0); + let far = shards[1].market_actors[&far_market.id].clone(); + let ReplyBody::Market(far_now) = shards[1].ask(&far, MarketMsg::Observe) else { + panic!("market actor did not answer"); + }; + assert_eq!(far_now.stock.food, 6); + + // Back home, the takings settle with the home market on the first shard. + merchant.job = Some(job.clone()); + (merchant.x, merchant.y) = home_market.site(); + let mut census = shards[0].census(); + let settled = shards[0].work(&merchant, &mut census); + assert_eq!(settled.job.unwrap().phase, Phase::Done); + let home = shards[0].market_actors[&home_market.id].clone(); + wait_until( + || { + matches!( + shards[0].ask(&home, MarketMsg::Observe), + ReplyBody::Market(m) if m.coins >= home_market.coins + job.coins - shards[0].config.merchant_escrow + ) + }, + "the merchant's settlement", + ); + } + + #[test] + fn markets_workplaces_and_carts_survive_snapshots() { + let mut world = World::new_with_script(42, economy_script()); + let shop = workplace_of(&world, Trade::Cartwright); + world.spawn_cart_actor(Cart { + id: 99, + x: shop.x, + y: shop.y, + owner: None, + wear: 5, + broken: false, + }); + let restored = World::from_snapshot_with_script( + &world.snapshot_json().unwrap(), + 42, + economy_script(), + Shard::SINGLE, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(restored.observe_markets()).unwrap(), + serde_json::to_value(world.observe_markets()).unwrap() + ); + assert_eq!( + serde_json::to_value(restored.observe_workplaces()).unwrap(), + serde_json::to_value(world.observe_workplaces()).unwrap() + ); + assert_eq!(restored.observe_carts()[0].wear, 5); + assert_eq!(restored.fields, world.fields); + let state: serde_json::Value = serde_json::from_str(&restored.state_json()).unwrap(); + assert_eq!(state["carts"][0][0], 99); + assert!(state["markets"].as_array().is_some_and(|m| !m.is_empty())); + } + fn is_fertile(world: &World, stage: LifeStage) -> bool { world .calendar @@ -3784,15 +4448,15 @@ mod tests { fn entity_actor_messages_round_trip_through_serde() { let message = PersonMsg::Perform( 7, - Activity { - action: "foraging".into(), - position: (3, 4), - gathered: Goods { + Activity::new( + "foraging", + (3, 4), + Goods { food: 2, - wood: 0, stone: 1, + ..Goods::default() }, - }, + ), ); let serialized = serde_json::to_string(&message).unwrap(); let PersonMsg::Perform(request, activity) = serde_json::from_str(&serialized).unwrap() diff --git a/src/world/actors.rs b/src/world/actors.rs index 3f916f7..a613c34 100644 --- a/src/world/actors.rs +++ b/src/world/actors.rs @@ -4,6 +4,7 @@ //! sends them the effects of world-level decisions (movement, gathering, hunting, births) and //! lets them apply entity-local rules (ageing, hunger, eating, energy, dying) themselves. +use super::economy::{Cart, Job, Market, Traded, Workplace}; use super::router::{Admission, EntityKey, Router}; use super::{Animal, CalendarSettings, LifeStage, Partnership, Person, Rng, sample_stage_duration}; use picard::actors::{ @@ -25,12 +26,7 @@ pub(super) type AnimalId = u64; const REPLY_TIMEOUT: Duration = Duration::from_secs(5); -#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] -pub(super) struct Goods { - pub food: u32, - pub wood: u32, - pub stone: u32, -} +pub(super) use super::economy::Goods; /// Inclusive map rectangle. #[derive(Clone, Copy, Debug, Deserialize, Serialize)] @@ -75,6 +71,32 @@ pub(super) struct Activity { pub action: String, pub position: (usize, usize), pub gathered: Goods, + /// Personal coins spent (positive) this tick. + #[serde(default)] + pub spent: u32, + /// Coins earned from personal sales this tick. + #[serde(default)] + pub earned: u32, + /// The person's job after this tick, when it changed. + #[serde(default)] + pub job: Option, + /// A cart the person bought this tick. + #[serde(default)] + pub cart: Option, +} + +impl Activity { + pub fn new(action: impl Into, position: (usize, usize), gathered: Goods) -> Self { + Self { + action: action.into(), + position, + gathered, + spent: 0, + earned: 0, + job: None, + cart: None, + } + } } #[derive(Clone, Copy, Debug, Deserialize, Serialize)] @@ -83,6 +105,7 @@ pub(super) enum PersonEdit { Food(u32), Wood(u32), Stone(u32), + Coins(u32), } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -95,6 +118,13 @@ pub(super) enum PersonMsg { Edit(PersonEdit), SetPartner(Option), MoveTo((usize, usize)), + /// A wage payment; `amount` is less than `owed` when the employer ran out of coins. + Paid { + amount: u32, + owed: u32, + }, + SetJob(Option), + SetCart(Option), /// Hands the person to `shard`, whose region they walked into; this actor then forwards /// every message to them until it is stopped. Depart(RequestId, usize), @@ -159,6 +189,15 @@ pub(super) enum ReplyBody { Partnership(Partnership), BirthDue(Partnership, bool), Departed, + Market(Market), + Workplace(Workplace), + Cart(Cart), + Hired(bool), + /// Goods and coins handed to a worker for their shift. + Consigned(Goods, u32), + Traded(Traded), + /// The cart bought and its price, if any. + CartBought(Option<(u64, u32)>), } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -167,7 +206,7 @@ pub(super) struct Reply { pub body: ReplyBody, } -fn reply(sender: Sender, request: RequestId, body: ReplyBody) { +pub(super) fn reply(sender: Sender, request: RequestId, body: ReplyBody) { // Messages sent without a reply address (e.g. from the actor console) get no answer. if let Some(sender) = sender { let _ = sender.try_tell(Reply { request, body }, None); @@ -229,6 +268,7 @@ impl PersonActor { food: std::mem::take(&mut person.food), wood: std::mem::take(&mut person.wood), stone: std::mem::take(&mut person.stone), + ..Goods::default() }; if person.hunger > needs.home_meal_threshold && needs.stock_food + outcome.deposited.food >= needs.food_per_meal @@ -254,6 +294,14 @@ impl PersonActor { person.food += activity.gathered.food; person.wood += activity.gathered.wood; person.stone += activity.gathered.stone; + person.tools += activity.gathered.tools; + person.coins = (person.coins + activity.earned).saturating_sub(activity.spent); + if let Some(job) = activity.job { + person.job = Some(job); + } + if activity.cart.is_some() { + person.cart = activity.cart; + } } fn edit(&mut self, edit: PersonEdit) { @@ -263,6 +311,7 @@ impl PersonActor { PersonEdit::Food(food) => person.food = food, PersonEdit::Wood(wood) => person.wood = wood, PersonEdit::Stone(stone) => person.stone = stone, + PersonEdit::Coins(coins) => person.coins = coins, } } } @@ -304,9 +353,23 @@ impl Actor for PersonActor { PersonMsg::Edit(edit) => self.edit(edit), PersonMsg::SetPartner(partner_id) => self.person.partner_id = partner_id, PersonMsg::MoveTo(position) => (self.person.x, self.person.y) = position, + PersonMsg::Paid { amount, owed } => { + let person = &mut self.person; + person.coins += amount; + person.unpaid_days = if amount < owed { + person.unpaid_days + 1 + } else { + 0 + }; + } + PersonMsg::SetJob(job) => { + self.person.job = job; + self.person.unpaid_days = 0; + } + PersonMsg::SetCart(cart) => self.person.cart = cart, PersonMsg::Depart(request, shard) => { self.router - .admit_at(shard, Admission::Person(self.person.clone())); + .admit_at(shard, Admission::Person(Box::new(self.person.clone()))); self.forward_to = Some(shard); reply(sender, request, ReplyBody::Departed); } diff --git a/src/world/economy.rs b/src/world/economy.rs new file mode 100644 index 0000000..aaf1d20 --- /dev/null +++ b/src/world/economy.rs @@ -0,0 +1,843 @@ +//! The economy's actors (workplaces, markets and carts), the jobs people hold, and how prices +//! are set and haggled over. +//! +//! Goods only enter the economy when people gather or craft them and only leave it when they +//! are eaten or used up. Coins never appear or vanish: wages, purchases and sales move them +//! between people, workplaces and markets. + +use super::actors::{PersonMsg, ReplyBody, RequestId, reply}; +use super::router::{Admission, EntityKey, Kind, Router}; +use picard::actors::{Actor, ActorFactoryArgs, Context as ActorContext, Sender}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub(super) struct Goods { + pub food: u32, + pub wood: u32, + pub stone: u32, + #[serde(default)] + pub tools: u32, +} + +impl Goods { + pub fn of(good: Good, quantity: u32) -> Self { + let mut goods = Self::default(); + *goods.get_mut(good) = quantity; + goods + } + + pub fn get(&self, good: Good) -> u32 { + match good { + Good::Food => self.food, + Good::Wood => self.wood, + Good::Stone => self.stone, + Good::Tools => self.tools, + Good::Carts => 0, + } + } + + /// The counter of `good`; carts are actors, not goods, so they have a scratch counter. + pub fn get_mut(&mut self, good: Good) -> &mut u32 { + match good { + Good::Food => &mut self.food, + Good::Wood => &mut self.wood, + Good::Stone => &mut self.stone, + Good::Tools | Good::Carts => &mut self.tools, + } + } + + pub fn total(&self) -> u32 { + self.food + self.wood + self.stone + self.tools + } + + pub fn add(&mut self, other: Goods) { + for good in Good::GOODS { + *self.get_mut(good) += other.get(good); + } + } +} + +/// Something traded on a market. Carts are actors and are traded one at a time. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub(super) enum Good { + Food, + Wood, + Stone, + Tools, + Carts, +} + +impl Good { + /// The goods that are carried as [`Goods`]. + pub const GOODS: [Good; 4] = [Good::Food, Good::Wood, Good::Stone, Good::Tools]; + pub const ALL: [Good; 5] = [ + Good::Food, + Good::Wood, + Good::Stone, + Good::Tools, + Good::Carts, + ]; + + pub fn index(self) -> usize { + self as usize + } +} + +/// What a workplace does; merchants work for markets. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub(super) enum Trade { + Farm, + Woodcutter, + Quarry, + Hunter, + Toolmaker, + Cartwright, + Merchant, +} + +impl Trade { + pub const WORKSHOPS: [Trade; 6] = [ + Trade::Farm, + Trade::Woodcutter, + Trade::Quarry, + Trade::Hunter, + Trade::Toolmaker, + Trade::Cartwright, + ]; + + pub fn key(self) -> &'static str { + match self { + Trade::Farm => "farm", + Trade::Woodcutter => "woodcutter", + Trade::Quarry => "quarry", + Trade::Hunter => "hunter", + Trade::Toolmaker => "toolmaker", + Trade::Cartwright => "cartwright", + Trade::Merchant => "merchant", + } + } + + /// Whether workers buy inputs at the market and craft them at the workplace. + pub fn crafts(self) -> bool { + matches!(self, Trade::Toolmaker | Trade::Cartwright) + } +} + +/// Where a worker is in their shift. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(super) enum Phase { + /// On the way to the workplace to start the day's shift. + Commute, + /// Gathering at the workplace. + Work, + /// A crafter buying inputs at the market. + Fetch, + /// A crafter turning inputs into goods at the workplace. + Craft, + /// Taking the day's goods to the market and settling with the employer. + Sell, + /// A merchant taking goods to a market elsewhere. + Travel, + /// A merchant bringing back what they bought. + Return, + Done, +} + +/// A person's employment, including the employer's goods and coins the worker carries. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub(super) struct Job { + pub employer: EntityKey, + pub trade: Trade, + pub site: (usize, usize), + pub market: EntityKey, + pub market_site: (usize, usize), + pub phase: Phase, + /// The day the last shift ended. + pub day: Option, + pub goods: Goods, + pub coins: u32, + /// Carts the worker built and still has to sell. + pub wares: Vec, + /// The market a merchant is travelling to. + pub destination: Option<(EntityKey, (usize, usize))>, +} + +impl Job { + pub fn new(employer: EntityKey, trade: Trade, site: (usize, usize), market: &Market) -> Self { + Self { + employer, + trade, + site, + market: market.key(), + market_site: market.site(), + phase: Phase::Done, + day: None, + goods: Goods::default(), + coins: 0, + wares: Vec::new(), + destination: None, + } + } + + pub fn shift_done(&self, today: u64) -> bool { + self.phase == Phase::Done && self.day == Some(today) + } + + pub fn on_shift(&self) -> bool { + !matches!(self.phase, Phase::Done | Phase::Commute) + } + + pub fn on_trip(&self) -> bool { + matches!(self.phase, Phase::Travel | Phase::Return) + } +} + +/// Base prices and the stock each market aims to hold, per [`Good`]. +#[derive(Clone, Copy, Debug, Deserialize, Serialize)] +pub(super) struct Pricing { + pub base: [u32; 5], + pub target: [u32; 5], + pub rounds: u32, +} + +impl Pricing { + /// The market price: twice the base price when out of stock, the base price at the target + /// stock, and falling towards zero as the stock grows. + pub fn price(&self, good: Good, stock: u32) -> u32 { + let base = u64::from(self.base[good.index()]); + let target = u64::from(self.target[good.index()].max(1)); + let price = base * 2 * target / (u64::from(stock) + target); + u32::try_from(price).unwrap_or(u32::MAX).max(1) + } + + pub fn base(&self, good: Good) -> u32 { + self.base[good.index()].max(1) + } +} + +/// One side's opening offer and the worst unit price it accepts. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(super) struct Stance { + pub open: u32, + pub limit: u32, +} + +impl Stance { + /// A seller asking half as much again as `price` and accepting no less than `limit`. + pub fn seller(price: u32, limit: u32) -> Self { + Self { + open: (price + price / 2).max(limit), + limit, + } + } + + /// A buyer opening at half of `price` and paying at most `limit`. + pub fn buyer(price: u32, limit: u32) -> Self { + Self { + open: (price / 2).min(limit), + limit, + } + } +} + +/// Alternating concessions: every round, seller and buyer each give up a share of the gap +/// between their opening offer and their limit, until the bid meets the ask. They settle in +/// the middle; there is no deal when the seller's limit is above the buyer's. +pub(super) fn haggle(seller: Stance, buyer: Stance, rounds: u32) -> Option { + if seller.limit > buyer.limit { + return None; + } + let seller_open = u64::from(seller.open.max(seller.limit)); + let buyer_open = u64::from(buyer.open.min(buyer.limit)); + let (seller_limit, buyer_limit) = (u64::from(seller.limit), u64::from(buyer.limit)); + let rounds = u64::from(rounds.max(1)); + for round in 0..=rounds { + let ask = seller_open - (seller_open - seller_limit) * round / rounds; + let bid = buyer_open + (buyer_limit - buyer_open) * round / rounds; + if bid >= ask { + return u32::try_from(ask.midpoint(bid)).ok(); + } + } + Some(seller.limit) +} + +/// The people working somewhere and what they are paid per shift. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(super) struct Staff { + pub employees: Vec, + pub positions: usize, + pub wage: u32, +} + +impl Staff { + fn hire(&mut self, person: u32) -> bool { + if self.employees.contains(&person) { + return true; + } + if self.employees.len() >= self.positions { + return false; + } + self.employees.push(person); + true + } + + /// Pays `person` their wage from `coins`, as far as the coins go. + fn pay(&self, router: &Router, coins: &mut u32, person: u32) { + if !self.employees.contains(&person) { + return; + } + let amount = self.wage.min(*coins); + *coins -= amount; + router.tell( + EntityKey::person(person), + PersonMsg::Paid { + amount, + owed: self.wage, + }, + ); + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct Workplace { + pub id: u64, + pub trade: Trade, + pub x: usize, + pub y: usize, + pub market: EntityKey, + pub market_site: (usize, usize), + pub coins: u32, + pub staff: Staff, + /// Farm fields, where crops are sown and harvested. + #[serde(default)] + pub fields: Vec<(usize, usize)>, +} + +impl Workplace { + pub fn key(&self) -> EntityKey { + EntityKey::new(Kind::Workplace, self.id) + } + + pub fn site(&self) -> (usize, usize) { + (self.x, self.y) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) enum WorkplaceMsg { + Observe(RequestId), + Hire(RequestId, u32), + /// A worker leaves, handing back the coins and goods they held for the workplace. + Resign { + person: u32, + coins: u32, + }, + /// A crafter starts their shift and takes up to `budget` coins to buy inputs. + ClockIn { + request: RequestId, + budget: u32, + }, + /// A worker hands over the day's takings and gets paid. + Settle { + person: u32, + coins: u32, + }, +} + +pub(super) struct WorkplaceActor { + workplace: Workplace, + router: Arc, +} + +impl ActorFactoryArgs<(Workplace, Arc)> for WorkplaceActor { + fn create_args((workplace, router): (Workplace, Arc)) -> Self { + Self { workplace, router } + } +} + +impl Actor for WorkplaceActor { + type Msg = WorkplaceMsg; + + fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, sender: Sender) { + let workplace = &mut self.workplace; + match msg { + WorkplaceMsg::Observe(request) => { + reply(sender, request, ReplyBody::Workplace(workplace.clone())); + } + WorkplaceMsg::Hire(request, person) => { + let hired = workplace.staff.hire(person); + reply(sender, request, ReplyBody::Hired(hired)); + } + WorkplaceMsg::Resign { person, coins } => { + workplace.coins += coins; + workplace.staff.employees.retain(|&id| id != person); + } + WorkplaceMsg::ClockIn { request, budget } => { + let advance = budget.min(workplace.coins); + workplace.coins -= advance; + reply( + sender, + request, + ReplyBody::Consigned(Goods::default(), advance), + ); + } + WorkplaceMsg::Settle { person, coins } => { + workplace.coins += coins; + workplace + .staff + .pay(&self.router, &mut workplace.coins, person); + } + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct Market { + pub id: u64, + pub x: usize, + pub y: usize, + pub coins: u32, + pub stock: Goods, + /// Carts for sale, parked at the market. + pub carts: Vec, + /// The market's merchants. + pub staff: Staff, +} + +impl Market { + pub fn key(&self) -> EntityKey { + EntityKey::new(Kind::Market, self.id) + } + + pub fn site(&self) -> (usize, usize) { + (self.x, self.y) + } + + pub fn stock_of(&self, good: Good) -> u32 { + match good { + Good::Carts => u32::try_from(self.carts.len()).unwrap_or(u32::MAX), + good => self.stock.get(good), + } + } + + pub fn price(&self, pricing: &Pricing, good: Good) -> u32 { + pricing.price(good, self.stock_of(good)) + } + + /// The market's stance when it buys: it opens low and pays up to 90% of its price. + fn bid(&self, pricing: &Pricing, good: Good) -> Stance { + let price = self.price(pricing, good); + Stance::buyer(price, (price * 9 / 10).max(1)) + } + + /// The market's stance when it sells: it opens high and asks at least 110% of its price. + fn ask(&self, pricing: &Pricing, good: Good) -> Stance { + let price = self.price(pricing, good); + Stance::seller(price, (price * 11 / 10).max(1)) + } + + pub fn quote(&self, pricing: &Pricing) -> Quote { + Quote { + market: self.key(), + site: self.site(), + prices: Good::ALL.map(|good| self.price(pricing, good)), + } + } + + /// Buys up to `quantity` of `good` from a seller with `stance`. + fn buy(&mut self, pricing: &Pricing, good: Good, quantity: u32, stance: Stance) -> Traded { + let Some(price) = haggle(stance, self.bid(pricing, good), pricing.rounds) else { + return Traded::NONE; + }; + let quantity = quantity.min(self.coins / price.max(1)); + self.coins -= quantity * price; + *self.stock.get_mut(good) += quantity; + Traded { quantity, price } + } + + /// Sells up to `quantity` of `good` to a buyer with `stance` who has `budget` coins. + fn sell( + &mut self, + pricing: &Pricing, + good: Good, + quantity: u32, + stance: Stance, + budget: u32, + ) -> Traded { + let Some(price) = haggle(self.ask(pricing, good), stance, pricing.rounds) else { + return Traded::NONE; + }; + let quantity = quantity + .min(self.stock.get(good)) + .min(budget / price.max(1)); + self.coins += quantity * price; + *self.stock.get_mut(good) -= quantity; + Traded { quantity, price } + } + + /// Hands a merchant the good the market holds the most surplus of, and coins to trade with. + fn consign(&mut self, pricing: &Pricing, capacity: u32, escrow: u32) -> (Goods, u32) { + let stock = self.stock; + let surplus = |good: Good| stock.get(good).saturating_sub(pricing.target[good.index()]); + let goods = Good::GOODS + .into_iter() + .max_by_key(|&good| surplus(good)) + .filter(|&good| surplus(good) > 0) + .map(|good| { + let quantity = surplus(good).min(capacity); + *self.stock.get_mut(good) -= quantity; + Goods::of(good, quantity) + }) + .unwrap_or_default(); + let coins = escrow.min(self.coins); + self.coins -= coins; + (goods, coins) + } +} + +/// The outcome of a trade: how much changed hands at which unit price. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +pub(super) struct Traded { + pub quantity: u32, + pub price: u32, +} + +impl Traded { + const NONE: Traded = Traded { + quantity: 0, + price: 0, + }; + + pub fn total(self) -> u32 { + self.quantity * self.price + } +} + +/// Market prices as published to every shard, so that merchants can pick where to go. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct Quote { + pub market: EntityKey, + pub site: (usize, usize), + pub prices: [u32; 5], +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) enum MarketMsg { + Observe(RequestId), + /// Someone with `stance` sells the market up to `quantity` of `good`. + Sell { + request: RequestId, + good: Good, + quantity: u32, + stance: Stance, + pricing: Pricing, + }, + /// Someone with `stance` and `budget` coins buys up to `quantity` of `good`. + Buy { + request: RequestId, + good: Good, + quantity: u32, + stance: Stance, + budget: u32, + pricing: Pricing, + }, + /// A cartwright sells the market a cart. + SellCart { + request: RequestId, + cart: u64, + stance: Stance, + pricing: Pricing, + }, + /// Someone buys one of the carts for sale. + BuyCart { + request: RequestId, + stance: Stance, + budget: u32, + pricing: Pricing, + }, + Hire(RequestId, u32), + Resign { + person: u32, + goods: Goods, + coins: u32, + }, + /// A merchant starts a trip and takes goods and up to `escrow` coins. + ClockIn { + request: RequestId, + capacity: u32, + escrow: u32, + pricing: Pricing, + }, + /// Goods handed over without payment, such as a leaving worker's unsold takings. + Stock(Goods), + /// A merchant hands over what they brought back and gets paid. + Settle { + person: u32, + goods: Goods, + coins: u32, + }, +} + +pub(super) struct MarketActor { + market: Market, + router: Arc, +} + +impl ActorFactoryArgs<(Market, Arc)> for MarketActor { + fn create_args((market, router): (Market, Arc)) -> Self { + Self { market, router } + } +} + +impl Actor for MarketActor { + type Msg = MarketMsg; + + fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, sender: Sender) { + let market = &mut self.market; + match msg { + MarketMsg::Observe(request) => { + reply(sender, request, ReplyBody::Market(market.clone())); + } + MarketMsg::Sell { + request, + good, + quantity, + stance, + pricing, + } => { + let traded = market.buy(&pricing, good, quantity, stance); + reply(sender, request, ReplyBody::Traded(traded)); + } + MarketMsg::Buy { + request, + good, + quantity, + stance, + budget, + pricing, + } => { + let traded = market.sell(&pricing, good, quantity, stance, budget); + reply(sender, request, ReplyBody::Traded(traded)); + } + MarketMsg::SellCart { + request, + cart, + stance, + pricing, + } => { + let bid = market.bid(&pricing, Good::Carts); + let traded = haggle(stance, bid, pricing.rounds) + .filter(|&price| price <= market.coins) + .map_or(Traded::NONE, |price| { + market.coins -= price; + market.carts.push(cart); + Traded { quantity: 1, price } + }); + reply(sender, request, ReplyBody::Traded(traded)); + } + MarketMsg::BuyCart { + request, + stance, + budget, + pricing, + } => { + let ask = market.ask(&pricing, Good::Carts); + let bought = haggle(ask, stance, pricing.rounds) + .filter(|&price| price <= budget && !market.carts.is_empty()) + .map(|price| { + market.coins += price; + (market.carts.remove(0), price) + }); + reply(sender, request, ReplyBody::CartBought(bought)); + } + MarketMsg::Hire(request, person) => { + let hired = market.staff.hire(person); + reply(sender, request, ReplyBody::Hired(hired)); + } + MarketMsg::Resign { + person, + goods, + coins, + } => { + market.stock.add(goods); + market.coins += coins; + market.staff.employees.retain(|&id| id != person); + } + MarketMsg::ClockIn { + request, + capacity, + escrow, + pricing, + } => { + let (goods, coins) = market.consign(&pricing, capacity, escrow); + reply(sender, request, ReplyBody::Consigned(goods, coins)); + } + MarketMsg::Stock(goods) => market.stock.add(goods), + MarketMsg::Settle { + person, + goods, + coins, + } => { + market.stock.add(goods); + market.coins += coins; + market.staff.pay(&self.router, &mut market.coins, person); + } + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) struct Cart { + pub id: u64, + pub x: usize, + pub y: usize, + pub owner: Option, + /// Tiles travelled so far; the cart breaks once it has travelled its durability. + pub wear: u32, + pub broken: bool, +} + +impl Cart { + pub fn key(&self) -> EntityKey { + EntityKey::new(Kind::Cart, self.id) + } + + pub fn position(&self) -> (usize, usize) { + (self.x, self.y) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(super) enum CartMsg { + Observe(RequestId), + /// Follows the owner to `to`, wearing out on the way. + Follow { + request: RequestId, + to: (usize, usize), + durability: u32, + }, + SetOwner(Option), + MoveTo((usize, usize)), + Depart(RequestId, usize), + Readmit(Cart), +} + +pub(super) struct CartActor { + cart: Cart, + router: Arc, + forward_to: Option, +} + +impl ActorFactoryArgs<(Cart, Arc)> for CartActor { + fn create_args((cart, router): (Cart, Arc)) -> Self { + Self { + cart, + router, + forward_to: None, + } + } +} + +impl Actor for CartActor { + type Msg = CartMsg; + + fn recv(&mut self, _ctx: &ActorContext, msg: Self::Msg, sender: Sender) { + if let Some(shard) = self.forward_to { + if let CartMsg::Readmit(cart) = msg { + self.cart = cart; + self.forward_to = None; + } else { + self.router.forward(shard, self.cart.key(), &msg); + } + return; + } + let cart = &mut self.cart; + match msg { + CartMsg::Observe(request) => reply(sender, request, ReplyBody::Cart(cart.clone())), + CartMsg::Follow { + request, + to, + durability, + } => { + let distance = cart.x.abs_diff(to.0).max(cart.y.abs_diff(to.1)); + cart.wear = cart + .wear + .saturating_add(u32::try_from(distance).unwrap_or(u32::MAX)); + (cart.x, cart.y) = to; + cart.broken |= cart.wear >= durability; + reply(sender, request, ReplyBody::Cart(cart.clone())); + } + CartMsg::SetOwner(owner) => cart.owner = owner, + CartMsg::MoveTo(position) => (cart.x, cart.y) = position, + CartMsg::Depart(request, shard) => { + self.router.admit_at(shard, Admission::Cart(cart.clone())); + self.forward_to = Some(shard); + reply(sender, request, ReplyBody::Departed); + } + CartMsg::Readmit(readmitted) => *cart = readmitted, + } + } +} + +#[cfg(test)] +mod tests { + use super::{Good, Goods, Market, Pricing, Staff, Stance, haggle}; + + fn pricing() -> Pricing { + Pricing { + base: [4, 2, 3, 14, 40], + target: [60, 40, 30, 8, 2], + rounds: 4, + } + } + + #[test] + fn haggling_meets_between_the_limits_or_fails() { + let seller = Stance { open: 12, limit: 4 }; + let buyer = Stance { open: 2, limit: 8 }; + let price = haggle(seller, buyer, 4).unwrap(); + assert!((4..=8).contains(&price)); + assert_eq!(haggle(Stance { open: 12, limit: 9 }, buyer, 4), None); + // A stubborn seller pulls the price up. + let stubborn = haggle(Stance { open: 30, limit: 4 }, buyer, 4).unwrap(); + assert!(stubborn >= price); + } + + #[test] + fn prices_follow_supply() { + let pricing = pricing(); + assert_eq!(pricing.price(Good::Food, 0), 8); + assert_eq!(pricing.price(Good::Food, 60), 4); + assert!(pricing.price(Good::Food, 600) < 4); + assert!(pricing.price(Good::Food, 1_000_000) >= 1); + } + + #[test] + fn markets_trade_within_their_coins_and_stock() { + let pricing = pricing(); + let mut market = Market { + id: 1, + x: 0, + y: 0, + coins: 10, + stock: Goods::default(), + carts: Vec::new(), + staff: Staff::default(), + }; + let sold = market.buy(&pricing, Good::Food, 100, Stance::seller(8, 1)); + assert!(sold.quantity > 0 && sold.total() <= 10); + assert_eq!(market.coins + sold.total(), 10); + assert_eq!(market.stock.food, sold.quantity); + let bought = market.sell(&pricing, Good::Food, 100, Stance::buyer(4, 100), 1000); + assert_eq!(bought.quantity, sold.quantity); + assert_eq!(market.stock.food, 0); + assert!( + bought.price >= sold.price, + "the market buys low and sells high" + ); + } +} diff --git a/src/world/router.rs b/src/world/router.rs index 1c50538..fdafbcc 100644 --- a/src/world/router.rs +++ b/src/world/router.rs @@ -10,6 +10,10 @@ use super::actors::{ AnimalActor, AnimalId, AnimalMsg, PartnershipActor, PartnershipMsg, PersonActor, PersonMsg, }; +use super::economy::{ + Cart, CartActor, CartMsg, Market, MarketActor, MarketMsg, Workplace, WorkplaceActor, + WorkplaceMsg, +}; use super::{Animal, Partnership, Person}; use picard::actors::{ActorRef, ActorRefFactory, ActorReference, ActorSystem, CreateError, Tell}; use picard::remoting::{RemoteError, RemoteHandler, RemoteRequest, RemoteResponse}; @@ -29,10 +33,21 @@ pub enum Kind { Rabbit, Wolf, Partnership, + Workplace, + Market, + Cart, } impl Kind { - const ALL: [Kind; 4] = [Kind::Person, Kind::Rabbit, Kind::Wolf, Kind::Partnership]; + const ALL: [Kind; 7] = [ + Kind::Person, + Kind::Rabbit, + Kind::Wolf, + Kind::Partnership, + Kind::Workplace, + Kind::Market, + Kind::Cart, + ]; fn name(self) -> &'static str { match self { @@ -40,6 +55,9 @@ impl Kind { Kind::Rabbit => "rabbit", Kind::Wolf => "wolf", Kind::Partnership => "partnership", + Kind::Workplace => "workplace", + Kind::Market => "market", + Kind::Cart => "cart", } } } @@ -138,6 +156,9 @@ pub(super) enum LocalActor { Person(ActorRef), Animal(ActorRef), Partnership(ActorRef), + Workplace(ActorRef), + Market(ActorRef), + Cart(ActorRef), } impl LocalActor { @@ -146,6 +167,9 @@ impl LocalActor { LocalActor::Person(actor) => actor.stop(), LocalActor::Animal(actor) => actor.stop(), LocalActor::Partnership(actor) => actor.stop(), + LocalActor::Workplace(actor) => actor.stop(), + LocalActor::Market(actor) => actor.stop(), + LocalActor::Cart(actor) => actor.stop(), } } } @@ -182,11 +206,39 @@ impl Routed for PartnershipMsg { } } +impl Routed for WorkplaceMsg { + fn local(actor: &LocalActor) -> Option<&ActorRef> { + match actor { + LocalActor::Workplace(actor) => Some(actor), + _ => None, + } + } +} + +impl Routed for MarketMsg { + fn local(actor: &LocalActor) -> Option<&ActorRef> { + match actor { + LocalActor::Market(actor) => Some(actor), + _ => None, + } + } +} + +impl Routed for CartMsg { + fn local(actor: &LocalActor) -> Option<&ActorRef> { + match actor { + LocalActor::Cart(actor) => Some(actor), + _ => None, + } + } +} + /// An entity's complete state, including its inventory, moving into another shard's region. #[derive(Clone, Debug, Deserialize, Serialize)] pub(super) enum Admission { - Person(Person), + Person(Box), Animal(Kind, AnimalId, Animal), + Cart(Cart), } impl Admission { @@ -194,14 +246,16 @@ impl Admission { match self { Admission::Person(person) => EntityKey::person(person.id), Admission::Animal(kind, id, _) => EntityKey::new(*kind, *id), + Admission::Cart(cart) => cart.key(), } } } /// An admitted entity, waiting for the world to pick it up at the start of its next step. pub(super) enum Arrival { - Person(Person, ActorRef), + Person(Box, ActorRef), Animal(Kind, AnimalId, ActorRef), + Cart(u64, ActorRef), } impl Arrival { @@ -209,6 +263,7 @@ impl Arrival { match self { Arrival::Person(person, _) => EntityKey::person(person.id), Arrival::Animal(kind, id, ..) => EntityKey::new(*kind, *id), + Arrival::Cart(id, _) => EntityKey::new(Kind::Cart, *id), } } } @@ -367,11 +422,11 @@ impl Router { let existing = self.local.read().expect("router lock").get(&key).cloned(); let arrival = match (admission, existing) { (Admission::Person(person), Some(LocalActor::Person(actor))) => { - actor.tell(PersonMsg::Readmit(person.clone()), None); + actor.tell(PersonMsg::Readmit((*person).clone()), None); Arrival::Person(person, actor) } (Admission::Person(person), _) => { - let Some(actor) = self.spawn_person(person.clone()) else { + let Some(actor) = self.spawn_person((*person).clone()) else { return; }; Arrival::Person(person, actor) @@ -386,6 +441,18 @@ impl Router { }; Arrival::Animal(kind, id, actor) } + (Admission::Cart(cart), Some(LocalActor::Cart(actor))) => { + let id = cart.id; + actor.tell(CartMsg::Readmit(cart), None); + Arrival::Cart(id, actor) + } + (Admission::Cart(cart), _) => { + let id = cart.id; + let Some(actor) = self.spawn_cart(cart) else { + return; + }; + Arrival::Cart(id, actor) + } }; self.forwarding.write().expect("router lock").remove(&key); arrivals.push(arrival); @@ -468,6 +535,36 @@ impl Router { Some(actor) } + pub(super) fn spawn_workplace(&self, workplace: Workplace) -> Option> { + let key = workplace.key(); + let actor = self.start(key, |name| { + self.system + .actor_of_args::(name, (workplace.clone(), self.arc())) + })?; + self.register(key, LocalActor::Workplace(actor.clone())); + Some(actor) + } + + pub(super) fn spawn_market(&self, market: Market) -> Option> { + let key = market.key(); + let actor = self.start(key, |name| { + self.system + .actor_of_args::(name, (market.clone(), self.arc())) + })?; + self.register(key, LocalActor::Market(actor.clone())); + Some(actor) + } + + pub(super) fn spawn_cart(&self, cart: Cart) -> Option> { + let key = cart.key(); + let actor = self.start(key, |name| { + self.system + .actor_of_args::(name, (cart.clone(), self.arc())) + })?; + self.register(key, LocalActor::Cart(actor.clone())); + Some(actor) + } + /// Answers a remoting request from another shard: admissions are taken in directly, entity /// messages go to Picard's remote dispatch, and messages for entities that are no longer /// here are forwarded to where they went. diff --git a/src/world/work.rs b/src/world/work.rs new file mode 100644 index 0000000..57f9cbd --- /dev/null +++ b/src/world/work.rs @@ -0,0 +1,1182 @@ +//! Jobs, markets and carts: the world drives the economy actors much like it drives people. +//! +//! The world only asks actors on this shard. A worker standing at a site is always on the +//! site's shard, because every site lies inside its shard's columns; everything else, such as +//! paying, settling and resigning, is told through the router and reaches the actor wherever +//! it lives. + +use super::actors::{Activity, PersonMsg, ReplyBody}; +use super::economy::{ + Cart, CartMsg, Good, Goods, Job, Market, MarketMsg, Phase, Pricing, Quote, Staff, Stance, + Trade, Traded, Workplace, WorkplaceMsg, +}; +use super::router::{EntityKey, Kind, LocalActor}; +use super::{ + CROP, Census, FOREST, GRASS, HILLS, LifeStage, Person, SAND, STONE, TREE, TradeSettings, World, + cheb, +}; +use picard::actors::{ActorRef, Tell}; +use std::collections::HashMap; +use std::fmt::Write as _; + +/// Minimum distance between two sites, so that their workers don't get in each other's way. +const SITE_SPACING: usize = 4; + +impl World { + pub(super) fn today(&self) -> u64 { + self.tick / self.calendar.ticks_per_day + } + + pub(super) fn work_time(&self) -> bool { + let time = self.day_fraction(); + time >= self.config.work_start && time < self.config.work_end + } + + /// The village a person lives in. + pub(super) fn home_of(&self, person: &Person) -> (usize, usize) { + person.home.unwrap_or(self.home) + } + + /// Whether a person is at home; only then do they use this shard's stockpile. + pub(super) fn at_home(&self, person: &Person) -> bool { + let home = self.home_of(person); + self.owns(home) && cheb(person.position(), home) <= self.config.home_radius + } + + /// How much a person can carry, more with a cart. + pub(super) fn capacity(&self, person: &Person) -> u32 { + let cart = if person.cart.is_some() { + self.config.cart_capacity + } else { + 0 + }; + self.config.carry_capacity + cart + } + + fn pricing(&self) -> Pricing { + let goods = [ + self.config.price_food, + self.config.price_wood, + self.config.price_stone, + self.config.price_tools, + self.config.price_carts, + ]; + Pricing { + base: goods.map(|good| good.price), + target: goods.map(|good| good.target), + rounds: self.config.haggle_rounds, + } + } + + fn trade_settings(&self, trade: Trade) -> TradeSettings { + match trade { + Trade::Farm => self.config.trade_farm, + Trade::Woodcutter => self.config.trade_woodcutter, + Trade::Quarry => self.config.trade_quarry, + Trade::Hunter => self.config.trade_hunter, + Trade::Toolmaker => self.config.trade_toolmaker, + Trade::Cartwright => self.config.trade_cartwright, + Trade::Merchant => self.config.trade_merchant, + } + } + + fn take_entity_id(&mut self) -> u64 { + let id = self.next_animal_id; + self.next_animal_id += self.shard.id_stride(); + id + } + + pub(super) fn spawn_market_actor(&mut self, market: Market) { + let (id, key) = (market.id, market.key()); + let actor = self + .router + .spawn_market(market) + .expect("failed to create Picard market actor"); + self.market_actors.insert(id, actor); + self.report_arrival(key); + } + + pub(super) fn spawn_workplace_actor(&mut self, workplace: Workplace) { + let (id, key) = (workplace.id, workplace.key()); + for &(x, y) in &workplace.fields { + self.fields.insert(y * self.width + x); + } + let actor = self + .router + .spawn_workplace(workplace) + .expect("failed to create Picard workplace actor"); + self.workplace_actors.insert(id, actor); + self.report_arrival(key); + } + + pub(super) fn spawn_cart_actor(&mut self, cart: Cart) { + let (id, key) = (cart.id, cart.key()); + let actor = self + .router + .spawn_cart(cart) + .expect("failed to create Picard cart actor"); + self.cart_actors.insert(id, actor); + self.report_arrival(key); + } + + fn retire_cart(&mut self, id: u64) { + if let Some(actor) = self.cart_actors.remove(&id) { + let key = EntityKey::new(Kind::Cart, id); + self.router.unregister(key); + LocalActor::Cart(actor).stop(); + self.report_departure(key); + } + } + + pub(super) fn observe_markets(&self) -> Vec { + let mut markets: Vec = self + .broadcast(&self.market_actors, MarketMsg::Observe) + .into_iter() + .map(|reply| match reply { + ReplyBody::Market(market) => market, + other => panic!("market actor answered an observation with {other:?}"), + }) + .collect(); + markets.sort_by_key(|market| market.id); + markets + } + + pub(super) fn observe_workplaces(&self) -> Vec { + let mut workplaces: Vec = self + .broadcast(&self.workplace_actors, WorkplaceMsg::Observe) + .into_iter() + .map(|reply| match reply { + ReplyBody::Workplace(workplace) => workplace, + other => panic!("workplace actor answered an observation with {other:?}"), + }) + .collect(); + workplaces.sort_by_key(|workplace| workplace.id); + workplaces + } + + pub(super) fn observe_carts(&self) -> Vec { + let mut carts: Vec = self + .broadcast(&self.cart_actors, CartMsg::Observe) + .into_iter() + .map(|reply| match reply { + ReplyBody::Cart(cart) => cart, + other => panic!("cart actor answered an observation with {other:?}"), + }) + .collect(); + carts.sort_by_key(|cart| cart.id); + carts + } + + /// The walkable tile of one of `terrain` in this shard nearest to the village, at least + /// `min_distance` from it and `spacing` from every tile in `taken`. + fn find_site( + &self, + origin: (usize, usize), + terrain: &[u8], + min_distance: usize, + spacing: usize, + taken: &[(usize, usize)], + ) -> Option<(usize, usize)> { + let mut best: Option<((usize, usize), usize)> = None; + for y in 0..self.height { + for x in self.columns() { + let tile = (x, y); + let distance = cheb(tile, origin); + if distance < min_distance + || !terrain.contains(&self.tiles[y * self.width + x]) + || !self.walkable(tile) + || self.houses.contains(&tile) + || taken.iter().any(|&other| cheb(other, tile) < spacing) + || best.is_some_and(|(_, nearest)| nearest <= distance) + { + continue; + } + best = Some((tile, distance)); + } + } + best.map(|(tile, _)| tile) + } + + /// Founds this shard's markets and workplaces near its village; a no-op when the economy + /// is disabled. + pub(super) fn found_economy(&mut self) { + let market_count = self.config.trade_merchant.count; + if market_count == 0 { + return; + } + let mut taken: Vec<(usize, usize)> = vec![self.home]; + let mut markets = Vec::new(); + let near = self.config.home_radius + 2; + for _ in 0..market_count { + let Some((x, y)) = self.find_site( + self.home, + &[GRASS, SAND, FOREST], + near, + SITE_SPACING, + &taken, + ) else { + break; + }; + taken.push((x, y)); + let settings = self.config.trade_merchant; + markets.push(Market { + id: self.take_entity_id(), + x, + y, + coins: self.config.initial_market_coins, + stock: Goods::default(), + carts: Vec::new(), + staff: Staff { + employees: Vec::new(), + positions: settings.workers, + wage: settings.wage, + }, + }); + } + if markets.is_empty() { + return; + } + let field_spacing = self.config.field_radius * 2 + 2; + for trade in Trade::WORKSHOPS { + let settings = self.trade_settings(trade); + for _ in 0..settings.count { + // Crafters buy and sell at the market every shift, so they settle next to it. + let crafter_origin = markets[0].site(); + let (origin, terrain, min_distance, spacing): (_, &[u8], usize, usize) = match trade + { + Trade::Farm => ( + self.home, + &[GRASS], + self.config.home_radius + 3, + field_spacing, + ), + Trade::Woodcutter => (self.home, &[FOREST], 10, SITE_SPACING), + Trade::Quarry => (self.home, &[HILLS], 10, SITE_SPACING), + Trade::Hunter => (self.home, &[GRASS], 10, SITE_SPACING), + _ => (crafter_origin, &[GRASS, SAND, FOREST], 1, 2), + }; + let Some(site) = self + .find_site(origin, terrain, min_distance, spacing, &taken) + .or_else(|| self.find_site(origin, terrain, near, spacing, &taken)) + else { + continue; + }; + taken.push(site); + let fields = if trade == Trade::Farm { + self.lay_fields(site, &taken) + } else { + Vec::new() + }; + taken.extend(fields.iter().copied()); + let market = markets + .iter() + .min_by_key(|market| cheb(market.site(), site)) + .expect("there is at least one market"); + let workplace = Workplace { + id: self.take_entity_id(), + trade, + x: site.0, + y: site.1, + market: market.key(), + market_site: market.site(), + coins: self.config.initial_workplace_coins, + staff: Staff { + employees: Vec::new(), + positions: settings.workers, + wage: settings.wage, + }, + fields, + }; + self.spawn_workplace_actor(workplace); + } + } + for market in markets { + self.spawn_market_actor(market); + } + self.refresh_board(); + } + + /// Sows the empty grass tiles around a farm. + fn lay_fields( + &mut self, + site: (usize, usize), + taken: &[(usize, usize)], + ) -> Vec<(usize, usize)> { + let radius = self.config.field_radius; + let mut fields = Vec::new(); + for y in site.1.saturating_sub(radius)..=(site.1 + radius).min(self.height - 1) { + for x in site.0.saturating_sub(radius)..=site.0 + radius { + let tile = (x, y); + let index = y * self.width + x; + if tile != site + && self.owns(tile) + && self.tiles[index] == GRASS + && self.res[index].0 == 0 + && !self.houses.contains(&tile) + && !taken.contains(&tile) + { + self.res[index] = (CROP, 1); + fields.push(tile); + } + } + } + fields + } + + pub(super) fn grow_crops(&mut self) { + let ripe = self.config.crop_ripe; + for &index in &self.fields { + let (kind, growth) = &mut self.res[index]; + if *kind == CROP && *growth < ripe { + *growth += 1; + } + } + } + + /// Publishes the prices of this shard's markets on the board. + pub(super) fn refresh_board(&mut self) { + let pricing = self.pricing(); + let quotes: Vec = self + .observe_markets() + .iter() + .map(|market| market.quote(&pricing)) + .collect(); + self.board + .retain(|quote| !quotes.iter().any(|local| local.market == quote.market)); + self.board.extend(quotes); + self.board.sort_by_key(|quote| quote.market); + } + + /// Daily hiring: people who went unpaid too long quit, and grown-ups living here who have + /// no job apply for the open positions, one trade after the other. + pub(super) fn employ(&mut self, people: &[Person]) { + if self.workplace_actors.is_empty() && self.market_actors.is_empty() { + return; + } + for person in people { + if person + .job + .as_ref() + .is_some_and(|job| job.phase == Phase::Done) + && person.unpaid_days >= self.config.quit_after_unpaid_days + { + self.leave_job(person); + self.tell_person(person.id, PersonMsg::SetJob(None)); + self.say(&format!("Person #{} quit their unpaid job", person.id)); + } + } + let mut seekers: Vec<&Person> = people + .iter() + .filter(|person| { + !person.dead + && person.job.is_none() + && matches!( + person.stage, + LifeStage::YoungAdult | LifeStage::Adult | LifeStage::OlderAdult + ) + && self.owns(self.home_of(person)) + && self.owns(person.position()) + }) + .collect(); + if seekers.is_empty() { + return; + } + let markets = self.observe_markets(); + let mut vacancies: Vec<(Job, usize)> = Vec::new(); + for workplace in self.observe_workplaces() { + let Some(market) = markets.iter().find(|m| m.key() == workplace.market) else { + continue; + }; + let open = workplace + .staff + .positions + .saturating_sub(workplace.staff.employees.len()); + let job = Job::new(workplace.key(), workplace.trade, workplace.site(), market); + vacancies.push((job, open)); + } + for market in &markets { + let open = market + .staff + .positions + .saturating_sub(market.staff.employees.len()); + vacancies.push(( + Job::new(market.key(), Trade::Merchant, market.site(), market), + open, + )); + } + while !seekers.is_empty() && vacancies.iter().any(|(_, open)| *open > 0) { + for (job, open) in &mut vacancies { + if *open == 0 { + continue; + } + let Some(person) = seekers.pop() else { + break; + }; + *open -= 1; + if self.hire(job.employer, person.id) { + self.tell_person(person.id, PersonMsg::SetJob(Some(job.clone()))); + } + } + } + } + + fn hire(&self, employer: EntityKey, person: u32) -> bool { + let reply = match employer.kind { + Kind::Market => self + .market_actors + .get(&employer.id) + .map(|actor| self.ask(actor, |request| MarketMsg::Hire(request, person))), + _ => self + .workplace_actors + .get(&employer.id) + .map(|actor| self.ask(actor, |request| WorkplaceMsg::Hire(request, person))), + }; + matches!(reply, Some(ReplyBody::Hired(true))) + } + + /// Hands back everything a person holds for their employer and takes them off the staff. + pub(super) fn leave_job(&self, person: &Person) { + let Some(job) = &person.job else { + return; + }; + if job.employer.kind == Kind::Market { + self.router.tell( + job.employer, + MarketMsg::Resign { + person: person.id, + goods: job.goods, + coins: job.coins, + }, + ); + } else { + self.router.tell( + job.employer, + WorkplaceMsg::Resign { + person: person.id, + coins: job.coins, + }, + ); + if job.goods.total() > 0 { + self.router.tell(job.market, MarketMsg::Stock(job.goods)); + } + } + for &cart in &job.wares { + self.router + .tell(EntityKey::new(Kind::Cart, cart), CartMsg::SetOwner(None)); + } + } + + /// Carts follow their owners; broken carts are scrapped. + pub(super) fn tick_carts(&mut self, census: &mut Census) { + if self.cart_actors.is_empty() { + return; + } + let positions: HashMap = census + .people + .iter() + .filter(|person| !person.dead) + .map(|person| (person.id, person.position())) + .collect(); + let request = self.mailbox.request(); + let mut following = 0; + for cart in &census.carts { + let Some(owner) = cart.owner else { + continue; + }; + let Some(actor) = self.cart_actors.get(&cart.id) else { + continue; + }; + match positions.get(&owner) { + Some(&to) if to != cart.position() => { + actor.tell( + CartMsg::Follow { + request, + to, + durability: self.config.cart_durability, + }, + self.mailbox.reply_to(), + ); + following += 1; + } + Some(_) => {} + None if !self.router.knows(EntityKey::person(owner)) => { + actor.tell(CartMsg::SetOwner(None), None); + } + None => {} + } + } + for reply in self.mailbox.collect(request, following) { + let ReplyBody::Cart(cart) = reply else { + panic!("cart actor answered a move with {reply:?}"); + }; + if let Some(entry) = census.carts.iter_mut().find(|c| c.id == cart.id) { + *entry = cart; + } + } + let broken: Vec = census.carts.iter().filter(|c| c.broken).cloned().collect(); + for cart in broken { + self.retire_cart(cart.id); + if let Some(owner) = cart.owner + && census + .people + .iter() + .any(|person| person.id == owner && person.cart == Some(cart.id)) + { + self.tell_person(owner, PersonMsg::SetCart(None)); + } + self.say(&format!("Cart #{} broke down", cart.id)); + } + census.carts.retain(|cart| !cart.broken); + } + + /// One tick of a person's job. + pub(super) fn work(&mut self, person: &Person, census: &mut Census) -> Activity { + let mut job = person.job.clone().expect("only employed people work"); + let today = self.today(); + let mut activity = match job.phase { + Phase::Done if job.day == Some(today) => { + let home = self.home_of(person); + Activity::new( + "going home", + self.move_toward(person.position(), home), + Goods::default(), + ) + } + Phase::Done | Phase::Commute => { + job.phase = Phase::Commute; + self.commute(person, &mut job) + } + Phase::Work => self.labour(person, &mut job, census), + Phase::Fetch => self.fetch(person, &mut job), + Phase::Craft => self.craft(person, &mut job), + Phase::Sell => self.sell(person, &mut job), + Phase::Travel => self.travel(person, &mut job), + Phase::Return => self.come_back(person, &mut job), + }; + if job.phase == Phase::Done && job.day != Some(today) && !job.on_trip() { + job.day = Some(today); + } + activity.job = Some(job); + activity + } + + /// Walks towards `to`; `None` once there. + fn walk(&mut self, person: &Person, to: (usize, usize), action: &str) -> Option { + let position = person.position(); + (position != to) + .then(|| Activity::new(action, self.move_toward(position, to), Goods::default())) + } + + fn stay(person: &Person, action: &str) -> Activity { + Activity::new(action, person.position(), Goods::default()) + } + + fn commute(&mut self, person: &Person, job: &mut Job) -> Activity { + if let Some(walking) = self.walk(person, job.site, "commuting") { + return walking; + } + match job.trade { + Trade::Merchant => self.set_out(person, job), + trade if trade.crafts() => { + let Some(actor) = self.workplace_actors.get(&job.employer.id) else { + return Self::stay(person, "waiting at the workshop"); + }; + let budget = self.config.craft_budget; + if let ReplyBody::Consigned(_, advance) = + self.ask(actor, |request| WorkplaceMsg::ClockIn { request, budget }) + { + job.coins += advance; + } + job.phase = if self.has_inputs(job) { + Phase::Craft + } else { + Phase::Fetch + }; + Self::stay(person, "clocking in") + } + _ => { + job.phase = Phase::Work; + Self::stay(person, "starting work") + } + } + } + + fn room(&self, person: &Person, job: &Job) -> u32 { + self.capacity(person) + .saturating_sub(person.carry() + job.goods.total()) + } + + /// A gatherer's shift: farming, chopping, quarrying or hunting near the site. + fn labour(&mut self, person: &Person, job: &mut Job, census: &mut Census) -> Activity { + let room = self.room(person, job); + if !self.work_time() || room == 0 { + job.phase = Phase::Sell; + return Self::stay(person, "packing up"); + } + let position = person.position(); + let radius = self.config.work_radius; + let near_site = |tile: (usize, usize)| cheb(tile, job.site) <= radius; + match job.trade { + Trade::Farm => self.farm(person, job, room), + Trade::Woodcutter | Trade::Quarry => { + let (kind, label) = if job.trade == Trade::Woodcutter { + (TREE, "felling trees") + } else { + (STONE, "quarrying stone") + }; + let target = self + .nearest_res(position, kind, radius) + .filter(|&tile| near_site(tile)) + .or_else(|| self.nearest_res(job.site, kind, radius)) + .or_else(|| self.nearest_res(job.site, kind, radius * 3)); + let Some(target) = target else { + return self + .walk(person, job.site, "looking for work") + .unwrap_or_else(|| Self::stay(person, "idle at work")); + }; + let (position, gathered) = self.gather(person, kind, target, room); + job.goods.add(gathered); + Activity::new(label, position, Goods::default()) + } + Trade::Hunter => { + let rabbit = Self::nearest_animal(&census.rabbits, job.site, radius) + .or_else(|| Self::nearest_animal(&census.rabbits, job.site, radius * 3)); + let Some(rabbit) = rabbit else { + return self + .walk(person, job.site, "tracking game") + .unwrap_or_else(|| Self::stay(person, "tracking game")); + }; + let target = census.rabbits[rabbit].1.position(); + if cheb(position, target) > self.config.attack_range { + return Activity::new( + "hunting", + self.move_toward(position, target), + Goods::default(), + ); + } + if self.rng.chance(self.config.person_hunt_success_chance) + && self.hunt_rabbit(census, rabbit) + { + job.goods.food += self.config.person_hunt_food_yield.min(room); + } + Self::stay(person, "hunting") + } + _ => { + job.phase = Phase::Sell; + Self::stay(person, "packing up") + } + } + } + + fn farm(&mut self, person: &Person, job: &mut Job, room: u32) -> Activity { + let ripe = self.config.crop_ripe; + let radius = self.config.field_radius; + let (sx, sy) = job.site; + let position = person.position(); + let mut best: Option<((usize, usize), (u8, usize))> = None; + for y in sy.saturating_sub(radius)..=(sy + radius).min(self.height - 1) { + for x in sx.saturating_sub(radius)..=(sx + radius).min(self.width - 1) { + let index = y * self.width + x; + if !self.fields.contains(&index) { + continue; + } + let (kind, growth) = self.res[index]; + // Harvesting comes before sowing, nearer tiles before farther ones. + let rank = match (kind, growth) { + (CROP, growth) if growth >= ripe => 0, + (0, _) => 1, + _ => continue, + }; + let score = (rank, cheb(position, (x, y))); + if best.is_none_or(|(_, best)| score < best) { + best = Some(((x, y), score)); + } + } + } + let Some((tile, _)) = best else { + return Self::stay(person, "tending fields"); + }; + if let Some(walking) = self.walk(person, tile, "tending fields") { + return walking; + } + let index = tile.1 * self.width + tile.0; + if self.res[index].0 == CROP { + self.res[index] = (0, 0); + job.goods.food += self.config.crop_yield.min(room); + Self::stay(person, "harvesting") + } else { + self.res[index] = (CROP, 1); + Self::stay(person, "sowing") + } + } + + /// The inputs one unit of a crafter's ware takes. + fn recipe(&self, trade: Trade) -> Goods { + match trade { + Trade::Toolmaker => Goods { + wood: self.config.tool_wood_cost, + stone: self.config.tool_stone_cost, + ..Goods::default() + }, + _ => Goods { + wood: self.config.cart_wood_cost, + ..Goods::default() + }, + } + } + + pub(super) fn household_food_target(&self) -> u32 { + self.config + .household_food_target + .saturating_mul(self.population_u32()) + } + + fn has_inputs(&self, job: &Job) -> bool { + let recipe = self.recipe(job.trade); + Good::GOODS + .iter() + .all(|&good| job.goods.get(good) >= recipe.get(good)) + } + + fn quote_of(&self, market: EntityKey) -> Option<&Quote> { + self.board.iter().find(|quote| quote.market == market) + } + + /// A crafter buys as many units' worth of inputs as the advance and their arms allow. + fn fetch(&mut self, person: &Person, job: &mut Job) -> Activity { + if let Some(walking) = self.walk(person, job.market_site, "fetching materials") { + return walking; + } + let Some(actor) = self.market_actors.get(&job.market.id).cloned() else { + return Self::stay(person, "waiting at the market"); + }; + let pricing = self.pricing(); + let recipe = self.recipe(job.trade); + let prices = self + .quote_of(job.market) + .map_or(pricing.base, |quote| quote.prices); + let unit_cost: u32 = Good::GOODS + .iter() + .map(|&good| recipe.get(good) * prices[good.index()]) + .sum(); + let room = self.room(person, job); + let units = (job.coins / unit_cost.max(1)).min(room / recipe.total().max(1)); + for good in Good::GOODS { + let quantity = recipe.get(good) * units; + if quantity == 0 { + continue; + } + let stance = Stance::buyer(prices[good.index()], pricing.base(good) * 2); + let traded = self.trade_at(&actor, |request| MarketMsg::Buy { + request, + good, + quantity, + stance, + budget: job.coins, + pricing, + }); + job.coins -= traded.total(); + *job.goods.get_mut(good) += traded.quantity; + } + job.phase = Phase::Craft; + Self::stay(person, "buying materials") + } + + fn trade_at( + &self, + market: &ActorRef, + message: impl FnOnce(u64) -> MarketMsg, + ) -> Traded { + match self.ask(market, message) { + ReplyBody::Traded(traded) => traded, + other => panic!("market actor answered a trade with {other:?}"), + } + } + + fn craft(&mut self, person: &Person, job: &mut Job) -> Activity { + if let Some(walking) = self.walk(person, job.site, "returning to the workshop") { + return walking; + } + if !self.work_time() || !self.has_inputs(job) { + job.phase = Phase::Sell; + return Self::stay(person, "packing up"); + } + let recipe = self.recipe(job.trade); + for good in Good::GOODS { + *job.goods.get_mut(good) -= recipe.get(good); + } + if job.trade == Trade::Cartwright { + let (x, y) = person.position(); + let cart = Cart { + id: self.take_entity_id(), + x, + y, + owner: Some(person.id), + wear: 0, + broken: false, + }; + job.wares.push(cart.id); + self.spawn_cart_actor(cart); + Self::stay(person, "building a cart") + } else { + job.goods.tools += 1; + Self::stay(person, "making tools") + } + } + + /// Sells the shift's goods at the market, settles with the employer and does the shopping. + fn sell(&mut self, person: &Person, job: &mut Job) -> Activity { + if let Some(walking) = self.walk(person, job.market_site, "taking goods to market") { + return walking; + } + let Some(actor) = self.market_actors.get(&job.market.id).cloned() else { + return Self::stay(person, "waiting at the market"); + }; + let pricing = self.pricing(); + let produce = if job.trade.crafts() { + vec![Good::Tools] + } else { + Good::GOODS.to_vec() + }; + for good in produce { + let quantity = job.goods.get(good); + if quantity == 0 { + continue; + } + let base = pricing.base(good); + let stance = Stance::seller(base * 2, (base / 2).max(1)); + let traded = self.trade_at(&actor, |request| MarketMsg::Sell { + request, + good, + quantity, + stance, + pricing, + }); + *job.goods.get_mut(good) -= traded.quantity; + job.coins += traded.total(); + } + let base = pricing.base(Good::Carts); + let stance = Stance::seller(base * 2, (base / 2).max(1)); + for cart in std::mem::take(&mut job.wares) { + let traded = self.trade_at(&actor, |request| MarketMsg::SellCart { + request, + cart, + stance, + pricing, + }); + if traded.quantity == 0 { + job.wares.push(cart); + continue; + } + job.coins += traded.total(); + let key = EntityKey::new(Kind::Cart, cart); + self.router.tell(key, CartMsg::SetOwner(None)); + self.router.tell(key, CartMsg::MoveTo(job.market_site)); + } + self.router.tell( + job.employer, + WorkplaceMsg::Settle { + person: person.id, + coins: std::mem::take(&mut job.coins), + }, + ); + job.phase = Phase::Done; + let mut activity = Self::stay(person, "selling goods"); + self.shop(person, job, &actor, &mut activity); + activity + } + + /// Shopping with a person's own coins: a meal when hungry, food for the household, + /// tools to work with and a cart once they can afford one. + fn shop( + &mut self, + person: &Person, + job: &Job, + market: &ActorRef, + activity: &mut Activity, + ) { + let pricing = self.pricing(); + let prices = self + .quote_of(job.market) + .map_or(pricing.base, |quote| quote.prices); + let mut purse = person + .coins + .saturating_sub(self.config.household_coin_reserve); + let mut room = self.room(person, job); + let mut wants: Vec<(Good, u32)> = Vec::new(); + if person.hunger > self.config.hunger_eat_carried_threshold + && person.food < self.config.food_per_meal + { + wants.push((Good::Food, self.config.food_per_meal * 2)); + } + let home = self.home_of(person); + let food_target = self.household_food_target(); + if person.tools == 0 { + // Saves up for a tool before spending on the household. + wants.push((Good::Tools, 1)); + } else if self.owns(home) && self.food < food_target { + wants.push((Good::Food, food_target - self.food)); + } + for (good, quantity) in wants { + let quantity = quantity.min(room); + if quantity == 0 || purse == 0 { + continue; + } + let stance = Stance::buyer(prices[good.index()], pricing.base(good) * 2); + let traded = self.trade_at(market, |request| MarketMsg::Buy { + request, + good, + quantity, + stance, + budget: purse, + pricing, + }); + purse -= traded.total(); + room -= traded.quantity; + activity.spent += traded.total(); + *activity.gathered.get_mut(good) += traded.quantity; + } + if person.cart.is_none() && person.coins >= self.config.cart_purchase_coins { + let price = prices[Good::Carts.index()]; + let stance = Stance::buyer(price, purse.min(pricing.base(Good::Carts) * 2)); + let reply = self.ask(market, |request| MarketMsg::BuyCart { + request, + stance, + budget: purse, + pricing, + }); + if let ReplyBody::CartBought(Some((cart, price))) = reply { + activity.spent += price; + activity.cart = Some(cart); + self.router.tell( + EntityKey::new(Kind::Cart, cart), + CartMsg::SetOwner(Some(person.id)), + ); + self.say(&format!("Person #{} bought a cart", person.id)); + } + } + } + + /// A merchant takes the market's surplus and coins and picks where to trade them. + fn set_out(&mut self, person: &Person, job: &mut Job) -> Activity { + let Some(actor) = self.market_actors.get(&job.employer.id).cloned() else { + return Self::stay(person, "waiting at the market"); + }; + let capacity = self.room(person, job); + let escrow = self.config.merchant_escrow; + let pricing = self.pricing(); + if let ReplyBody::Consigned(goods, coins) = self.ask(&actor, |request| MarketMsg::ClockIn { + request, + capacity, + escrow, + pricing, + }) { + job.goods.add(goods); + job.coins += coins; + } + job.destination = self.choose_destination(job); + if job.destination.is_some() { + job.phase = Phase::Travel; + return Self::stay(person, "setting out to trade"); + } + self.router.tell( + job.employer, + MarketMsg::Settle { + person: person.id, + goods: std::mem::take(&mut job.goods), + coins: std::mem::take(&mut job.coins), + }, + ); + job.phase = Phase::Done; + let mut activity = Self::stay(person, "finding no trade"); + self.shop(person, job, &actor, &mut activity); + activity + } + + /// The market where the goods carried fetch more than at home, or where something is + /// cheaper than at home; `None` when no trip pays off. + fn choose_destination(&self, job: &Job) -> Option<(EntityKey, (usize, usize))> { + let home = self.quote_of(job.market)?; + self.board + .iter() + .filter(|quote| quote.market != job.market) + .map(|quote| { + let selling: i64 = Good::GOODS + .iter() + .map(|&good| { + i64::from(job.goods.get(good)) + * (i64::from(quote.prices[good.index()]) + - i64::from(home.prices[good.index()])) + }) + .sum(); + let buying = Good::GOODS + .iter() + .map(|&good| { + i64::from(home.prices[good.index()]) - i64::from(quote.prices[good.index()]) + }) + .max() + .unwrap_or(0) + .max(0) + * i64::from(self.config.carry_capacity); + (selling + buying, quote) + }) + .filter(|(gain, _)| *gain > 0) + .max_by_key(|(gain, quote)| (*gain, std::cmp::Reverse(quote.market))) + .map(|(_, quote)| (quote.market, quote.site)) + } + + /// At the far market: sell what fetches more than at home and buy what is cheaper here. + fn travel(&mut self, person: &Person, job: &mut Job) -> Activity { + let Some((destination, site)) = job.destination else { + job.phase = Phase::Return; + return Self::stay(person, "heading home"); + }; + if let Some(walking) = self.walk(person, site, "travelling to trade") { + return walking; + } + let Some(actor) = self.market_actors.get(&destination.id).cloned() else { + return Self::stay(person, "waiting at the market"); + }; + let pricing = self.pricing(); + let home = self.quote_of(job.market).map_or(pricing.base, |q| q.prices); + let here = self + .quote_of(destination) + .map_or(pricing.base, |q| q.prices); + for good in Good::GOODS { + let quantity = job.goods.get(good); + if quantity == 0 { + continue; + } + let stance = Stance::seller(here[good.index()], home[good.index()].max(1)); + let traded = self.trade_at(&actor, |request| MarketMsg::Sell { + request, + good, + quantity, + stance, + pricing, + }); + *job.goods.get_mut(good) -= traded.quantity; + job.coins += traded.total(); + } + let bargain = Good::GOODS + .into_iter() + .filter(|good| home[good.index()] > here[good.index()]) + .max_by_key(|good| home[good.index()] - here[good.index()]); + if let Some(good) = bargain { + let quantity = self.room(person, job); + let stance = Stance::buyer(here[good.index()], home[good.index()] - 1); + let budget = job.coins; + let traded = self.trade_at(&actor, |request| MarketMsg::Buy { + request, + good, + quantity, + stance, + budget, + pricing, + }); + job.coins -= traded.total(); + *job.goods.get_mut(good) += traded.quantity; + } + job.phase = Phase::Return; + let mut activity = Self::stay(person, "trading"); + self.buy_meal(person, &actor, &mut activity); + activity + } + + /// A travelling merchant buys a meal with their own coins when hungry. + fn buy_meal(&mut self, person: &Person, market: &ActorRef, activity: &mut Activity) { + if person.hunger <= self.config.hunger_eat_carried_threshold + || person.food >= self.config.food_per_meal + { + return; + } + let pricing = self.pricing(); + let good = Good::Food; + let quantity = self.config.food_per_meal * 2; + let stance = Stance::buyer(pricing.base(good), pricing.base(good) * 2); + let budget = person.coins; + let traded = self.trade_at(market, |request| MarketMsg::Buy { + request, + good, + quantity, + stance, + budget, + pricing, + }); + activity.spent += traded.total(); + activity.gathered.food += traded.quantity; + } + + fn come_back(&mut self, person: &Person, job: &mut Job) -> Activity { + if let Some(walking) = self.walk(person, job.market_site, "returning from a trip") { + return walking; + } + self.router.tell( + job.employer, + MarketMsg::Settle { + person: person.id, + goods: std::mem::take(&mut job.goods), + coins: std::mem::take(&mut job.coins), + }, + ); + job.destination = None; + job.phase = Phase::Done; + let mut activity = Self::stay(person, "settling the trip"); + if let Some(actor) = self.market_actors.get(&job.market.id).cloned() { + self.shop(person, job, &actor, &mut activity); + } + activity + } + + /// Appends the markets, workplaces and carts to the entities JSON; leaves the last array + /// open like the resource array before it. + pub(super) fn write_economy_json(&self, s: &mut String) { + s.push_str("],\"markets\":["); + for (index, market) in self.observe_markets().iter().enumerate() { + if index > 0 { + s.push(','); + } + let stock = market.stock; + let _ = write!( + s, + "[{},{},{},{},{},{},{},{},{}]", + market.id, + market.x, + market.y, + market.coins, + stock.food, + stock.wood, + stock.stone, + stock.tools, + market.carts.len() + ); + } + s.push_str("],\"workplaces\":["); + for (index, workplace) in self.observe_workplaces().iter().enumerate() { + if index > 0 { + s.push(','); + } + let _ = write!( + s, + "[{},{},{},\"{}\",{},{},{}]", + workplace.id, + workplace.x, + workplace.y, + workplace.trade.key(), + workplace.coins, + workplace.staff.employees.len(), + workplace.staff.positions + ); + } + s.push_str("],\"carts\":["); + for (index, cart) in self.observe_carts().iter().enumerate() { + if index > 0 { + s.push(','); + } + let owner = cart + .owner + .map_or_else(|| "null".to_string(), |owner| owner.to_string()); + let _ = write!(s, "[{},{},{},{}]", cart.id, cart.x, cart.y, owner); + } + } +}