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>
This commit is contained in:
Steve Biedermann 2026-10-12 20:10:22 +02:00
parent bc58855ff5
commit bdf819f721
7 changed files with 2956 additions and 48 deletions

View File

@ -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

View File

@ -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";
}

File diff suppressed because it is too large Load Diff

View File

@ -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<Job>,
/// A cart the person bought this tick.
#[serde(default)]
pub cart: Option<u64>,
}
impl Activity {
pub fn new(action: impl Into<String>, 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<u32>),
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<Job>),
SetCart(Option<u64>),
/// 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);
}

843
src/world/economy.rs Normal file
View File

@ -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<u64>,
pub goods: Goods,
pub coins: u32,
/// Carts the worker built and still has to sell.
pub wares: Vec<u64>,
/// 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<u32> {
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<u32>,
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<Router>,
}
impl ActorFactoryArgs<(Workplace, Arc<Router>)> for WorkplaceActor {
fn create_args((workplace, router): (Workplace, Arc<Router>)) -> Self {
Self { workplace, router }
}
}
impl Actor for WorkplaceActor {
type Msg = WorkplaceMsg;
fn recv(&mut self, _ctx: &ActorContext<Self::Msg>, 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<u64>,
/// 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<Router>,
}
impl ActorFactoryArgs<(Market, Arc<Router>)> for MarketActor {
fn create_args((market, router): (Market, Arc<Router>)) -> Self {
Self { market, router }
}
}
impl Actor for MarketActor {
type Msg = MarketMsg;
fn recv(&mut self, _ctx: &ActorContext<Self::Msg>, 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<u32>,
/// 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<u32>),
MoveTo((usize, usize)),
Depart(RequestId, usize),
Readmit(Cart),
}
pub(super) struct CartActor {
cart: Cart,
router: Arc<Router>,
forward_to: Option<usize>,
}
impl ActorFactoryArgs<(Cart, Arc<Router>)> for CartActor {
fn create_args((cart, router): (Cart, Arc<Router>)) -> Self {
Self {
cart,
router,
forward_to: None,
}
}
}
impl Actor for CartActor {
type Msg = CartMsg;
fn recv(&mut self, _ctx: &ActorContext<Self::Msg>, 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"
);
}
}

View File

@ -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<PersonMsg>),
Animal(ActorRef<AnimalMsg>),
Partnership(ActorRef<PartnershipMsg>),
Workplace(ActorRef<WorkplaceMsg>),
Market(ActorRef<MarketMsg>),
Cart(ActorRef<CartMsg>),
}
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<Self>> {
match actor {
LocalActor::Workplace(actor) => Some(actor),
_ => None,
}
}
}
impl Routed for MarketMsg {
fn local(actor: &LocalActor) -> Option<&ActorRef<Self>> {
match actor {
LocalActor::Market(actor) => Some(actor),
_ => None,
}
}
}
impl Routed for CartMsg {
fn local(actor: &LocalActor) -> Option<&ActorRef<Self>> {
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<Person>),
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<PersonMsg>),
Person(Box<Person>, ActorRef<PersonMsg>),
Animal(Kind, AnimalId, ActorRef<AnimalMsg>),
Cart(u64, ActorRef<CartMsg>),
}
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<ActorRef<WorkplaceMsg>> {
let key = workplace.key();
let actor = self.start(key, |name| {
self.system
.actor_of_args::<WorkplaceActor, _>(name, (workplace.clone(), self.arc()))
})?;
self.register(key, LocalActor::Workplace(actor.clone()));
Some(actor)
}
pub(super) fn spawn_market(&self, market: Market) -> Option<ActorRef<MarketMsg>> {
let key = market.key();
let actor = self.start(key, |name| {
self.system
.actor_of_args::<MarketActor, _>(name, (market.clone(), self.arc()))
})?;
self.register(key, LocalActor::Market(actor.clone()));
Some(actor)
}
pub(super) fn spawn_cart(&self, cart: Cart) -> Option<ActorRef<CartMsg>> {
let key = cart.key();
let actor = self.start(key, |name| {
self.system
.actor_of_args::<CartActor, _>(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.

1182
src/world/work.rs Normal file

File diff suppressed because it is too large Load Diff