use crate::{ comp::inventory::{slot::InvSlotId, Inventory}, uid::Uid, }; use hashbrown::HashMap; use serde::{Deserialize, Serialize}; use tracing::{trace, warn}; #[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum TradePhase { Mutate, Review, Complete, } /// Clients submit `TradeAction` to the server, which adds the Uid of the /// player out-of-band (i.e. without trusting the client to say who it's /// accepting on behalf of) #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum TradeAction { AddItem { item: InvSlotId, quantity: u32, ours: bool, }, RemoveItem { item: InvSlotId, quantity: u32, ours: bool, }, /// Accept needs the phase indicator to avoid progressing too far in the /// trade if there's latency and a player presses the accept button /// multiple times Accept(TradePhase), Decline, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum TradeResult { Completed, Declined, NotEnoughSpace, } /// Items are not removed from the inventory during a PendingTrade: all the /// items are moved atomically (if there's space and both parties agree) upon /// completion /// /// Since this stores `InvSlotId`s (i.e. references into inventories) instead of /// items themselves, there aren't any duplication/loss risks from things like /// dropped connections or declines, since the server doesn't have to move items /// from a trade back into a player's inventory. /// /// On the flip side, since they are references to *slots*, if a player could /// swap items in their inventory during a trade, they could mutate the trade, /// enabling them to remove an item from the trade even after receiving the /// counterparty's phase2 accept. To prevent this, we disallow all /// forms of inventory manipulation in `server::events::inventory_manip` if /// there's a pending trade that's past phase1 (in phase1, the trade should be /// mutable anyway). /// /// Inventory manipulation in phase1 may be beneficial to trade (e.g. splitting /// a stack of items, once that's implemented), but should reset both phase1 /// accept flags to make the changes more visible. /// /// Another edge case prevented by using `InvSlotId`s is that it disallows /// trading currently-equipped items (since `EquipSlot`s are disjoint from /// `InvSlotId`s), which avoids the issues associated with trading equipped bags /// that may still have contents. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PendingTrade { /// `parties[0]` is the entity that initiated the trade, parties[1] is the /// other entity that's being traded with pub parties: [Uid; 2], /// `offers[i]` represents the items and quantities of the party i's items /// being offered pub offers: [HashMap; 2], /// The current phase of the trade pub phase: TradePhase, /// `accept_flags` indicate that which parties wish to proceed to the next /// phase of the trade pub accept_flags: [bool; 2], } impl TradePhase { fn next(self) -> TradePhase { match self { TradePhase::Mutate => TradePhase::Review, TradePhase::Review => TradePhase::Complete, TradePhase::Complete => TradePhase::Complete, } } } impl PendingTrade { pub fn new(party: Uid, counterparty: Uid) -> PendingTrade { PendingTrade { parties: [party, counterparty], offers: [HashMap::new(), HashMap::new()], phase: TradePhase::Mutate, accept_flags: [false, false], } } pub fn phase(&self) -> TradePhase { self.phase } pub fn should_commit(&self) -> bool { matches!(self.phase, TradePhase::Complete) } pub fn which_party(&self, party: Uid) -> Option { self.parties .iter() .enumerate() .find(|(_, x)| **x == party) .map(|(i, _)| i) } /// Invariants: /// - A party is never shown as offering more of an item than they own /// - Offers with a quantity of zero get removed from the trade /// - Modifications can only happen in phase 1 /// - Whenever a trade is modified, both accept flags get reset /// - Accept flags only get set for the current phase pub fn process_trade_action( &mut self, mut who: usize, action: TradeAction, inventories: &[&Inventory], ) { use TradeAction::*; match action { AddItem { item, quantity: delta, ours, } => { if self.phase() == TradePhase::Mutate && delta > 0 { if !ours { who = 1 - who; } let total = self.offers[who].entry(item).or_insert(0); let owned_quantity = inventories[who].get(item).map(|i| i.amount()).unwrap_or(0); *total = total.saturating_add(delta).min(owned_quantity); self.accept_flags = [false, false]; } }, RemoveItem { item, quantity: delta, ours, } => { if self.phase() == TradePhase::Mutate { if !ours { who = 1 - who; } self.offers[who] .entry(item) .and_replace_entry_with(|_, mut total| { total = total.saturating_sub(delta); if total > 0 { Some(total) } else { None } }); self.accept_flags = [false, false]; } }, Accept(phase) => { if self.phase == phase { self.accept_flags[who] = true; } if self.accept_flags[0] && self.accept_flags[1] { self.phase = self.phase.next(); self.accept_flags = [false, false]; } }, Decline => {}, } } } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct TradeId(usize); pub struct Trades { pub next_id: TradeId, pub trades: HashMap, pub entity_trades: HashMap, } impl Trades { pub fn begin_trade(&mut self, party: Uid, counterparty: Uid) -> TradeId { let id = self.next_id; self.next_id = TradeId(id.0.wrapping_add(1)); self.trades .insert(id, PendingTrade::new(party, counterparty)); self.entity_trades.insert(party, id); self.entity_trades.insert(counterparty, id); id } pub fn process_trade_action<'a, F: Fn(Uid) -> Option<&'a Inventory>>( &mut self, id: TradeId, who: Uid, action: TradeAction, get_inventory: F, ) { trace!("for trade id {:?}, message {:?}", id, action); if let Some(trade) = self.trades.get_mut(&id) { if let Some(party) = trade.which_party(who) { let mut inventories = Vec::new(); for party in trade.parties.iter() { match get_inventory(*party) { Some(inventory) => inventories.push(inventory), None => return, } } trade.process_trade_action(party, action, &*inventories); } else { warn!( "An entity who is not a party to trade {:?} tried to modify it", id ); } } else { warn!("Attempt to modify nonexistent trade id {:?}", id); } } pub fn decline_trade(&mut self, id: TradeId, who: Uid) -> Option { let mut to_notify = None; if let Some(trade) = self.trades.remove(&id) { match trade.which_party(who) { Some(i) => { self.entity_trades.remove(&trade.parties[0]); self.entity_trades.remove(&trade.parties[1]); // let the other person know the trade was declined to_notify = Some(trade.parties[1 - i]) }, None => { warn!( "An entity who is not a party to trade {:?} tried to decline it", id ); // put it back self.trades.insert(id, trade); }, } } else { warn!("Attempt to decline nonexistent trade id {:?}", id); } to_notify } /// See the doc comment on `common::trade::PendingTrade` for the /// significance of these checks pub fn in_trade_with_property bool>( &self, uid: &Uid, f: F, ) -> bool { self.entity_trades .get(uid) .and_then(|trade_id| self.trades.get(trade_id)) .map(f) // if any of the option lookups failed, we're not in any trade .unwrap_or(false) } pub fn in_immutable_trade(&self, uid: &Uid) -> bool { self.in_trade_with_property(uid, |trade| trade.phase() != TradePhase::Mutate) } pub fn in_mutable_trade(&self, uid: &Uid) -> bool { self.in_trade_with_property(uid, |trade| trade.phase() == TradePhase::Mutate) } pub fn implicit_mutation_occurred(&mut self, uid: &Uid) { if let Some(trade_id) = self.entity_trades.get(uid) { self.trades .get_mut(trade_id) .map(|trade| trade.accept_flags = [false, false]); } } } impl Default for Trades { fn default() -> Trades { Trades { next_id: TradeId(0), trades: HashMap::new(), entity_trades: HashMap::new(), } } }