veloren_common/
trade.rs

1use std::cmp::Ordering;
2
3use crate::{
4    comp::inventory::{
5        Inventory, item::ItemDefinitionIdOwned, slot::InvSlotId, trade_pricing::TradePricing,
6    },
7    terrain::BiomeKind,
8    uid::Uid,
9};
10use hashbrown::HashMap;
11use serde::{Deserialize, Serialize};
12use strum::EnumIter;
13use tracing::{trace, warn};
14
15#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub enum TradePhase {
17    Mutate,
18    Review,
19    Complete,
20}
21
22/// Clients submit `TradeAction` to the server, which adds the Uid of the
23/// player out-of-band (i.e. without trusting the client to say who it's
24/// accepting on behalf of)
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub enum TradeAction {
27    AddItem {
28        item: InvSlotId,
29        quantity: u32,
30        ours: bool,
31    },
32    RemoveItem {
33        item: InvSlotId,
34        quantity: u32,
35        ours: bool,
36    },
37    /// Accept needs the phase indicator to avoid progressing too far in the
38    /// trade if there's latency and a player presses the accept button
39    /// multiple times
40    Accept(TradePhase),
41    Decline,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45pub enum TradeResult {
46    Completed,
47    Declined,
48    NotEnoughSpace,
49}
50
51/// Items are not removed from the inventory during a PendingTrade: all the
52/// items are moved atomically (if there's space and both parties agree) upon
53/// completion
54///
55/// Since this stores `InvSlotId`s (i.e. references into inventories) instead of
56/// items themselves, there aren't any duplication/loss risks from things like
57/// dropped connections or declines, since the server doesn't have to move items
58/// from a trade back into a player's inventory.
59///
60/// On the flip side, since they are references to *slots*, if a player could
61/// swap items in their inventory during a trade, they could mutate the trade,
62/// enabling them to remove an item from the trade even after receiving the
63/// counterparty's phase2 accept. To prevent this, we disallow all
64/// forms of inventory manipulation in `server::events::inventory_manip` if
65/// there's a pending trade that's past phase1 (in phase1, the trade should be
66/// mutable anyway).
67///
68/// Inventory manipulation in phase1 may be beneficial to trade (e.g. splitting
69/// a stack of items, once that's implemented), but should reset both phase1
70/// accept flags to make the changes more visible.
71///
72/// Another edge case prevented by using `InvSlotId`s is that it disallows
73/// trading currently-equipped items (since `EquipSlot`s are disjoint from
74/// `InvSlotId`s), which avoids the issues associated with trading equipped bags
75/// that may still have contents.
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77pub struct PendingTrade {
78    /// `parties[0]` is the entity that initiated the trade, parties[1] is the
79    /// other entity that's being traded with
80    pub parties: [Uid; 2],
81    /// `offers[i]` represents the items and quantities of the party i's items
82    /// being offered
83    pub offers: [HashMap<InvSlotId, u32>; 2],
84    /// The current phase of the trade
85    pub phase: TradePhase,
86    /// `accept_flags` indicate that which parties wish to proceed to the next
87    /// phase of the trade
88    pub accept_flags: [bool; 2],
89}
90
91impl TradePhase {
92    fn next(self) -> TradePhase {
93        match self {
94            TradePhase::Mutate => TradePhase::Review,
95            TradePhase::Review => TradePhase::Complete,
96            TradePhase::Complete => TradePhase::Complete,
97        }
98    }
99}
100
101impl TradeAction {
102    pub fn item(item: InvSlotId, delta: i32, ours: bool) -> Option<Self> {
103        match delta.cmp(&0) {
104            Ordering::Equal => None,
105            Ordering::Less => Some(TradeAction::RemoveItem {
106                item,
107                ours,
108                quantity: -delta as u32,
109            }),
110            Ordering::Greater => Some(TradeAction::AddItem {
111                item,
112                ours,
113                quantity: delta as u32,
114            }),
115        }
116    }
117}
118
119impl PendingTrade {
120    pub fn new(party: Uid, counterparty: Uid) -> PendingTrade {
121        PendingTrade {
122            parties: [party, counterparty],
123            offers: [HashMap::new(), HashMap::new()],
124            phase: TradePhase::Mutate,
125            accept_flags: [false, false],
126        }
127    }
128
129    pub fn phase(&self) -> TradePhase { self.phase }
130
131    pub fn should_commit(&self) -> bool { matches!(self.phase, TradePhase::Complete) }
132
133    pub fn which_party(&self, party: Uid) -> Option<usize> {
134        self.parties
135            .iter()
136            .enumerate()
137            .find(|(_, x)| **x == party)
138            .map(|(i, _)| i)
139    }
140
141    pub fn is_empty_trade(&self) -> bool { self.offers[0].is_empty() && self.offers[1].is_empty() }
142
143    /// Invariants:
144    /// - A party is never shown as offering more of an item than they own
145    /// - Offers with a quantity of zero get removed from the trade
146    /// - Modifications can only happen in phase 1
147    /// - Whenever a trade is modified, both accept flags get reset
148    /// - Accept flags only get set for the current phase
149    pub fn process_trade_action(
150        &mut self,
151        mut who: usize,
152        action: TradeAction,
153        inventories: &[&Inventory],
154    ) {
155        use TradeAction::*;
156        match action {
157            AddItem {
158                item,
159                quantity: delta,
160                ours,
161            } => {
162                if self.phase() == TradePhase::Mutate && delta > 0 {
163                    if !ours {
164                        who = 1 - who;
165                    }
166                    let total = self.offers[who].entry(item).or_insert(0);
167                    let owned_quantity =
168                        inventories[who].get(item).map(|i| i.amount()).unwrap_or(0);
169                    *total = total.saturating_add(delta).min(owned_quantity);
170                    self.accept_flags = [false, false];
171                }
172            },
173            RemoveItem {
174                item,
175                quantity: delta,
176                ours,
177            } => {
178                if self.phase() == TradePhase::Mutate {
179                    if !ours {
180                        who = 1 - who;
181                    }
182                    self.offers[who]
183                        .entry(item)
184                        .and_replace_entry_with(|_, mut total| {
185                            total = total.saturating_sub(delta);
186                            if total > 0 { Some(total) } else { None }
187                        });
188                    self.accept_flags = [false, false];
189                }
190            },
191            Accept(phase) => {
192                if self.phase == phase && !self.is_empty_trade() {
193                    self.accept_flags[who] = true;
194                }
195                if self.accept_flags[0] && self.accept_flags[1] {
196                    self.phase = self.phase.next();
197                    self.accept_flags = [false, false];
198                }
199            },
200            Decline => {},
201        }
202    }
203}
204
205#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
206pub struct TradeId(usize);
207
208pub struct Trades {
209    pub next_id: TradeId,
210    pub trades: HashMap<TradeId, PendingTrade>,
211    pub entity_trades: HashMap<Uid, TradeId>,
212}
213
214impl Trades {
215    pub fn begin_trade(&mut self, party: Uid, counterparty: Uid) -> TradeId {
216        let id = self.next_id;
217        self.next_id = TradeId(id.0.wrapping_add(1));
218        self.trades
219            .insert(id, PendingTrade::new(party, counterparty));
220        self.entity_trades.insert(party, id);
221        self.entity_trades.insert(counterparty, id);
222        id
223    }
224
225    pub fn process_trade_action<'a, F: Fn(Uid) -> Option<&'a Inventory>>(
226        &mut self,
227        id: TradeId,
228        who: Uid,
229        action: TradeAction,
230        get_inventory: F,
231    ) {
232        trace!("for trade id {:?}, message {:?}", id, action);
233        if let Some(trade) = self.trades.get_mut(&id) {
234            if let Some(party) = trade.which_party(who) {
235                let mut inventories = Vec::new();
236                for party in trade.parties.iter() {
237                    match get_inventory(*party) {
238                        Some(inventory) => inventories.push(inventory),
239                        None => return,
240                    }
241                }
242                trade.process_trade_action(party, action, &inventories);
243            } else {
244                warn!(
245                    "An entity who is not a party to trade {:?} tried to modify it",
246                    id
247                );
248            }
249        } else {
250            warn!("Attempt to modify nonexistent trade id {:?}", id);
251        }
252    }
253
254    pub fn decline_trade(&mut self, id: TradeId, who: Uid) -> Option<Uid> {
255        let mut to_notify = None;
256        if let Some(trade) = self.trades.remove(&id) {
257            match trade.which_party(who) {
258                Some(i) => {
259                    self.entity_trades.remove(&trade.parties[0]);
260                    self.entity_trades.remove(&trade.parties[1]);
261                    // let the other person know the trade was declined
262                    to_notify = Some(trade.parties[1 - i])
263                },
264                None => {
265                    warn!(
266                        "An entity who is not a party to trade {:?} tried to decline it",
267                        id
268                    );
269                    // put it back
270                    self.trades.insert(id, trade);
271                },
272            }
273        } else {
274            warn!("Attempt to decline nonexistent trade id {:?}", id);
275        }
276        to_notify
277    }
278
279    /// See the doc comment on `common::trade::PendingTrade` for the
280    /// significance of these checks
281    pub fn in_trade_with_property<F: FnOnce(&PendingTrade) -> bool>(
282        &self,
283        uid: &Uid,
284        f: F,
285    ) -> bool {
286        self.entity_trades
287            .get(uid)
288            .and_then(|trade_id| self.trades.get(trade_id))
289            .map(f)
290            // if any of the option lookups failed, we're not in any trade
291            .unwrap_or(false)
292    }
293
294    pub fn in_immutable_trade(&self, uid: &Uid) -> bool {
295        self.in_trade_with_property(uid, |trade| trade.phase() != TradePhase::Mutate)
296    }
297
298    pub fn in_mutable_trade(&self, uid: &Uid) -> bool {
299        self.in_trade_with_property(uid, |trade| trade.phase() == TradePhase::Mutate)
300    }
301
302    pub fn implicit_mutation_occurred(&mut self, uid: &Uid) {
303        if let Some(trade_id) = self.entity_trades.get(uid) {
304            self.trades
305                .get_mut(trade_id)
306                .map(|trade| trade.accept_flags = [false, false]);
307        }
308    }
309}
310
311impl Default for Trades {
312    fn default() -> Trades {
313        Trades {
314            next_id: TradeId(0),
315            trades: HashMap::new(),
316            entity_trades: HashMap::new(),
317        }
318    }
319}
320
321// we need this declaration in common for Merchant loadout creation, it is not
322// directly related to trade between entities, but between sites (more abstract)
323// economical information
324#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize, EnumIter)]
325pub enum Good {
326    Territory(BiomeKind),
327    Flour,
328    Meat,
329    Terrain(BiomeKind),
330    Transportation,
331    Food,
332    Wood,
333    Stone,
334    Tools, // weapons, farming tools
335    Armor,
336    Ingredients, // raw material for Armor+Tools+Potions
337    Potions,
338    Coin, // exchange material across sites
339    RoadSecurity,
340    Recipe,
341}
342
343impl Default for Good {
344    fn default() -> Self {
345        Good::Terrain(BiomeKind::Void) // Arbitrary
346    }
347}
348
349impl Good {
350    /// The discounting factor applied when selling goods back to a merchant
351    pub fn trade_margin(&self) -> f32 {
352        match self {
353            Good::Tools | Good::Armor => 0.5,
354            Good::Food | Good::Potions | Good::Ingredients | Good::Wood => 0.75,
355            Good::Coin | Good::Recipe => 1.0,
356            // Certain abstract goods (like Territory) shouldn't be attached to concrete items;
357            // give a sale price of 0 if the player is trying to sell a concrete item that somehow
358            // has one of these categories
359            _ => 0.0,
360        }
361    }
362}
363
364// ideally this would be a real Id<Site> but that is from the world crate
365pub type SiteId = u64;
366
367#[derive(Clone, Debug)]
368pub struct SiteInformation {
369    pub id: SiteId,
370    pub unconsumed_stock: HashMap<Good, f32>,
371}
372
373#[derive(Clone, Debug, Default, Serialize, Deserialize)]
374pub struct SitePrices {
375    pub values: HashMap<Good, f32>,
376}
377
378impl SitePrices {
379    pub fn balance(
380        &self,
381        offers: &[HashMap<InvSlotId, u32>; 2],
382        inventories: &[Option<ReducedInventory>; 2],
383        who: usize,
384        reduce: bool,
385    ) -> Option<f32> {
386        offers[who]
387            .iter()
388            .map(|(slot, amount)| {
389                inventories[who]
390                    .as_ref()
391                    .map(|ri| {
392                        let item = ri.inventory.get(slot)?;
393                        let vec = TradePricing::get_materials(&item.name.as_ref())?;
394                        Some(
395                            vec.iter()
396                                .map(|(amount2, material)| {
397                                    self.values.get(material).copied().unwrap_or_default()
398                                        * *amount2
399                                        * (if reduce { material.trade_margin() } else { 1.0 })
400                                })
401                                .sum::<f32>()
402                                * (*amount as f32),
403                        )
404                    })
405                    .unwrap_or(Some(0.0))
406            })
407            .try_fold(0.0, |a, p| Some(a + p?))
408    }
409}
410
411#[derive(Clone, Debug)]
412pub struct ReducedInventoryItem {
413    pub name: ItemDefinitionIdOwned,
414    pub amount: u32,
415}
416
417#[derive(Clone, Debug, Default)]
418pub struct ReducedInventory {
419    pub inventory: HashMap<InvSlotId, ReducedInventoryItem>,
420}
421
422impl ReducedInventory {
423    pub fn from(inventory: &Inventory) -> Self {
424        let items = inventory
425            .slots_with_id()
426            .filter(|(_, it)| it.is_some())
427            .map(|(sl, it)| {
428                (sl, ReducedInventoryItem {
429                    name: it.as_ref().unwrap().item_definition_id().to_owned(),
430                    amount: it.as_ref().unwrap().amount(),
431                })
432            })
433            .collect();
434        Self { inventory: items }
435    }
436}