veloren_common/
trade.rs

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