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>
2026-10-12 18:10:22 +00:00
|
|
|
//! 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};
|
2026-10-13 14:33:23 +00:00
|
|
|
use super::router::{EntityKey, Kind, Router};
|
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>
2026-10-12 18:10:22 +00:00
|
|
|
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)),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(super) struct CartActor {
|
|
|
|
|
cart: Cart,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-13 14:33:23 +00:00
|
|
|
impl ActorFactoryArgs<Cart> for CartActor {
|
|
|
|
|
fn create_args(cart: Cart) -> Self {
|
|
|
|
|
Self { cart }
|
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>
2026-10-12 18:10:22 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Actor for CartActor {
|
|
|
|
|
type Msg = CartMsg;
|
|
|
|
|
|
|
|
|
|
fn recv(&mut self, _ctx: &ActorContext<Self::Msg>, msg: Self::Msg, sender: Sender) {
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|