diff --git a/contrib/barter.py b/contrib/barter.py new file mode 100644 index 0000000000..27c6452e30 --- /dev/null +++ b/contrib/barter.py @@ -0,0 +1,738 @@ +""" +Barter system + +Evennia contribution - Griatch 2012 + + +This implements a full barter system - a way for players to safely +trade items between each other using code rather than simple free-form +talking. The advantage of this is increased buy/sell safety but it +also streamlines the process and makes it faster when doing many +transactions (since goods are automatically exchanged once both +agree). + +This system is primarily intended for a barter economy, but can easily +be used in a monetary economy as well -- just let the "goods" on one +side be coin objects (this is more flexible than a simple "buy" +command since you can mix coins and goods in your trade). + +In this module, a "barter" is generally referred to as a "trade". + + +- Trade example + +A trade (barter) action works like this: A and B are the parties. + +1) opening a trade + +A: trade B: Hi, I have a nice extra sword. You wanna trade? +B sees: A says: "Hi, I have a nice extra sword. You wanna trade?" + A wants to trade with you. Enter 'trade A ' to accept. +B: trade A: Hm, I could use a good sword ... +A sees: B says: "Hm, I could use a good sword ... + B accepts the trade. Use 'trade help' for aid. +B sees: You are now trading with A. Use 'trade help' for aid. + +2) negotiating + +A: offer sword: This is a nice sword. I would need some rations in trade. +B sees: A says: "This is a nice sword. I would need some rations in trade." + [A offers Sword of might.] +B evalute sword +B sees: +B: offer ration: This is a prime ration. +A sees: B says: "These is a prime ration." + [B offers iron ration] +A: say Hey, this is a nice sword, I need something more for it. +B sees: A says: "Hey this is a nice sword, I need something more for it." +B: offer sword,apple: Alright. I will also include a magic apple. That's my last offer. +A sees: B says: "Alright, I will also include a magic apple. That's my last offer." + [B offers iron ration and magic apple] +A accept: You are killing me here, but alright. +B sees: A says: "You are killing me here, but alright." + [A accepts your offer. You must now also accept.] +B accept: Good, nice making business with you. + You accept the deal. Deal is made and goods changed hands. +A sees: B says: "Good, nice making business with you." + B accepts the deal. Deal is made and goods changed hands. + +At this point the trading system is exited and the negotiated items +are automatically exchanged between the parties. In this example B was +the only one changing their offer, but also A could have changed their +offer until the two parties found something they could agree on. The +emotes are optional but useful for RP-heavy worlds. + +- Technical info + +The trade is implemented by use of a TradeHandler. This object is a +common place for storing the current status of negotiations. It is +created on the object initiating the trade, and also stored on the +other party once that party agrees to trade. The trade request times +out after a certain time - this is handled by a Script. Once trade +starts, the CmdsetTrade cmdset is initiated on both parties along with +the commands relevant for the trading. + +- Ideas for NPC bartering: + +This module is primarily intended for trade between two players. But +it can also in principle be used for a player negotiating with an +AI-controlled NPC. If the NPC uses normal commands they can use it +directly -- but more efficient is to have the NPC object send its +replies directly through the tradehandler to the player. One may want +to add some functionality to the decline command, so players can +decline specific objects in the NPC offer (decline ) and allow +the AI to maybe offer something else and make it into a proper +barter. Along with an AI that "needs" things or has some sort of +personality in the trading, this can make bartering with NPCs at least +moderately more interesting than just plain 'buy'. + +- Installation: + +Just import the CmdTrade command into (for example) the default +cmdset. This will make the trade (or barter) command available +in-game. + +""" + +from ev import Command, Script, CmdSet + +TRADE_TIMEOUT = 60 # timeout for B to accept trade + + +class TradeTimeout(Script): + """ + This times out the trade request, in case player B did not reply in time. + """ + def at_script_creation(self): + "called when script is first created" + self.key = "trade_request_timeout" + self.desc = "times out trade requests" + self.interval = TRADE_TIMEOUT + self.start_delay = True + self.repeats = 1 + self.persistent = False + def at_repeat(self): + "called once" + if self.ndb.tradeevent: + self.obj.ndb.tradeevent.finish(force=True) + self.obj.msg("Trade request timed out.") + def is_valid(self): + "Only valid if the trade has not yet started" + return self.obj.ndb.tradeevent and not self.obj.ndb.tradeevent.trade_started + +class TradeHandler(object): + """ + Objects of this class handles the ongoing trade, notably storing the current + offers from each side and wether both have accepted or not. + """ + def __init__(self, partA, partB): + """ + Initializes the trade. This is called when part A tries to initiate + a trade with part B. The trade will not start until part B repeats + this command (B will then call the self.join() command) + + We also store the back-reference from the respective party to this object. + """ + # parties + self.partA = partA + self.partB = partB + + self.partA.cmdset.add(CmdsetTrade()) + self.trade_started = False + self.partA.ndb.tradehandler = self + # trade variables + self.partA_offers = [] + self.partB_offers = [] + self.partA_accepted = False + self.partB_accepted = False + # start a timer + def msg(self, party, string): + """ + Relay a message to the other party. This allows + the calling command to not have to worry about + which party they are in the handler. + """ + if self.partA == party: + self.partB.msg(string) + elif self.partB == party: + self.partA.msg(string) + else: + # no match, relay to oneself + self.party.msg(string) + def get_other(self, party): + "Returns the other party of the trade" + if self.partA == party: + return self.partB + if self.partB == party: + return self.partA + return None + + def join(self, partB): + """ + This is used once B decides to join the trade + """ + print "join:", self.partB, partB, self.partB == partB, type(self.partB),type(partB) + if self.partB == partB: + self.partB.ndb.tradehandler = self + self.partB.cmdset.add(CmdsetTrade()) + self.trade_started = True + return True + return False + def unjoin(self, partB): + """ + This is used if B decides not to join the trade + """ + if self.partB == partB: + self.finish() + return True + return False + + def offer(self, party, *args): + """ + Change the current standing offer. We leave it up to the + command to do the actual checks that the offer consists + of real, valid, objects. + """ + if self.trade_started: + # reset accept statements whenever an offer changes + self.partA_accepted = False + self.partB_accepted = False + if party == self.partA: + self.partA_offers = list(args) + elif party == self.partB: + self.partB_offers = list(args) + else: + raise ValueError + def list(self): + """ + Returns two lists of objects on offer, separated by partA/B. + """ + return self.partA_offers, self.partB_offers + + def search(self, offername): + """ + Returns an object on offer, based on a search criterion. + If the search criterion is an integer, treat it as an + index to return in the list of offered items + """ + all_offers = self.partA_offers + self.partB_offers + if isinstance(offername, int): + # an index to return + if 0 <= offername < len(all_offers): + return all_offers[offername] + + all_keys = [offer.key for offer in all_offers] + try: + imatch = all_keys.index(offername) + return all_offers[imatch] + except ValueError: + for offer in all_offers: + if offername in offer.aliases: + return offer + return None + + def accept(self, party): + """ + Accept the current offer. + + Returns True if this closes the deal, False otherwise + """ + if self.trade_started: + if party == self.partA: + self.partA_accepted = True + elif party == self.partB: + self.partB_accepted = True + else: + raise ValueError + return self.finish() # try to close the deal + def decline(self, party): + """ + Remove an previously accepted status (changing ones mind) + + returns True if there was really a status to change, False otherwise. + """ + if self.trade_started: + if party == self.partA: + if self.partA_accepted: + self.partA_accepted = False + return True + return False + elif party == self.partB: + if self.partB_accepted: + self.partB_accepted = False + return True + return False + else: + raise ValueError + def finish(self, force=False): + """ + Conclude trade - move all offers and clean up + """ + fin = False + if self.trade_started and self.partA_accepted and self.partB_accepted: + # both accepted - move objects before cleanup + for obj in self.partA_offers: + obj.location = self.partB + for obj in self.partB_offers: + obj.location = self.partA + fin = True + if fin or force: + # cleanup + self.partA.cmdset.delete("cmdset_trade") + self.partB.cmdset.delete("cmdset_trade") + self.partA_offers = None + self.partB_offers = None + del self.partA.ndb.tradehandler # this will kill it also from partB + if self.partB.ndb.tradehandler: + del self.partB.ndb.tradehandler + return True + +# trading commands (will go into CmdsetTrade, initialized by the +# CmdTrade command further down). + +class CmdTradeBase(Command): + """ + Base command for Trade commands to inherit from. Implements + the custom parsing. + """ + def parse(self): + """ + Parse the relevant parts and make it easily + available to the command + """ + self.args = self.args.strip() + self.tradehandler = self.caller.ndb.tradehandler + self.partA = self.tradehandler.partA + self.partB = self.tradehandler.partB + + self.other = self.tradehandler.get_other(self.caller) + self.msg_other = self.tradehandler.msg + + self.trade_started = self.tradehandler.trade_started + self.emote = "" + self.str_caller = "Your trade action: %s" + self.str_other = "%s:s trade action: " % self.caller.key + "%s" + if ':' in self.args: + self.args, self.emote = [part.strip() for part in self.args.rsplit(":", 1)] + self.str_caller = 'You say, "' + self.emote + '"\n [%s]' + if self.caller.has_player: + self.str_other = '{c%s{n says, "' % self.caller.key + self.emote + '"\n [%s]' + else: + self.str_other = '%s says, "' % self.caller.key + self.emote + '"\n [%s]' + +# trade help + +class CmdTradeHelp(CmdTradeBase): + """ + help command for the trade system. + + Usage: + trade help + + Displays help for the trade commands. + """ + key = "trade help" + #aliases = ["trade help"] + locks = "cmd:all()" + help_category = "Trade" + + def func(self): + "Show the help" + string = """ + Trading commands + + {woffer [:emote]{n + offer one or more objects for trade. The emote can be used for RP/arguments. + A new offer will require both parties to re-accept it again. + {waccept [:emote]{n + accept the currently standing offer from both sides. Also 'agree' works. + Once both have accepted, the deal is finished and goods will change hands. + {wdecline [:emote]{n + change your mind and remove a previous accept (until other has also accepted) + {wstatus{n + show the current offers on each side of the deal. Also 'offers' and 'deal' works. + {wevaluate or {n + examine any offer in the deal. List them with the 'status' command. + {wend trade{n + end the negotiations prematurely. No trade will take place. + + You can also use {wemote{n, {wsay{n etc to discuss without making a decision or offer. + """ + self.caller.msg(string) + +# offer + +class CmdOffer(CmdTradeBase): + """ + offer one or more items in trade. + + Usage: + offer [, object2, ...][:emote] + + Offer objects in trade. This will replace the currently + standing offer. + """ + key = "offer" + locks = "cmd:all()" + help_category = "Trading" + + def func(self): + "implement the offer" + + caller = self.caller + if not self.args: + caller.msg("Usage: offer [, object2, ...] [:emote]") + return + if not self.trade_started: + caller.msg("Wait until the other party has accepted to trade with you.") + return + + # gather all offers + offers = [part.strip() for part in self.args.split(',')] + offerobjs = [] + for offername in offers: + obj = caller.search(offername) + if not obj: + return + offerobjs.append(obj) + self.tradehandler.offer(self.caller, *offerobjs) + + # output + if len(offerobjs) > 1: + objnames = ", ".join("{w%s{n" % obj.key for obj in offerobjs[:-1]) + " and {w%s{n" % offerobjs[-1].key + else: + objnames = "{w%s{n" % offerobjs[0].key + + caller.msg(self.str_caller % ("You offer %s" % objnames)) + self.msg_other(caller, self.str_other % ("They offer %s" % objnames)) + +# accept + +class CmdAccept(CmdTradeBase): + """ + accept the standing offer + + Usage: + accept [:emote] + agreee [:emote] + + This will accept the current offer. The other party must also accept + for the deal to go through. You can use the 'decline' command to change + your mind as long as the other party has not yet accepted. You can inspect + the current offer using the 'offers' command. + """ + key = "accept" + aliases = ["agree"] + locks = "cmd:all()" + help_category = "Trading" + + def func(self): + "accept the offer" + caller = self.caller + if not self.trade_started: + caller.msg("Wait until the other party has accepted to trade with you.") + return + if self.tradehandler.accept(self.caller): + # deal finished. Trade ended and cleaned. + caller.msg(self.str_caller % "You {gaccept{n the deal. {gDeal is made and goods changed hands.{n") + self.msg_other(caller, self.str_other % "%s {gaccepts{n the deal. {gDeal is made and goods changed hands.{n" % caller.key) + else: + # a one-sided accept. + caller.msg(self.str_caller % "You {Gaccept{n the offer. %s must now also accept." % self.other.key) + self.msg_other(caller, self.str_other % "%s {Gaccepts{n the offer. You must now also accept." % caller.key) + +# decline + +class CmdDecline(CmdTradeBase): + """ + decline the standing offer + + Usage: + decline [:emote] + + This will decline a previously 'accept'ed offer (so this allows you to + change your mind). You can only use this as long as the other party + has not yet accepted the deal. Also, changing the offer will automatically + decline the old offer. + """ + key = "decline" + locks = "cmd:all()" + help_category = "Trading" + + def func(self): + "decline the offer" + caller = self.caller + if not self.trade_started: + caller.msg("Wait until the other party has accepted to trade with you.") + return + offerA, offerB = self.tradehandler.list() + if not offerA or not offerB: + caller.msg("Noone has offered anything (yet) so there is nothing to decline.") + return + if self.tradehandler.decline(self.caller): + # changed a previous accept + caller.msg(self.str_caller % "You change your mind, {Rdeclining{n the current offer.") + self.msg_other(caller, self.str_other % "%s changes their mind, {Rdeclining{n the current offer." % caller.key) + else: + # no acceptance to change + caller.msg(self.str_caller % "You {Rdecline{n the current offer.") + self.msg_other(caller, self.str_other % "%s declines the current offer." % caller.key) + + +# evaluate + +# Note: This version only shows the description. If your particular game +# lists other important properties of objects (such as weapon damage, weight, +# magical properties, ammo requirements or whatnot), then you need to add this +# here. + +class CmdEvaluate(CmdTradeBase): + """ + evaluate objects on offer + + Usage: + evaluate + + This allows you to examine any object currently on offer, to + determine if it's worth your while. + """ + key = "evaluate" + aliases = ["eval"] + locks = "cmd:all()" + help_category = "Trading" + + def func(self): + "evaluate an object" + caller = self.caller + if not self.args: + caller.msg("Usage: evaluate ") + return + # we also accept indices + try: + ind = int(self.args) + self.args = ind - 1 + except Exception: + pass + + offer = self.tradehandler.search(self.args) + if not offer: + caller.msg("No offer matching '%s' was found." % self.args) + return + # show the description + caller.msg(offer.db.desc) + +# status + +class CmdStatus(CmdTradeBase): + """ + show a list of the current deal + + Usage: + status + deal + offers + + Shows the currently suggested offers on each sides of the deal. To + accept the current deal, use the 'accept' command. Use 'offer' to + change your deal. You might also want to use 'say', 'emote' etc to + try to influence the other part in the deal. + """ + key = "status" + aliases = ["offers", "deal"] + locks = "cmd:all()" + help_category = "Trading" + + def func(self): + "Show the current deal" + caller = self.caller + partA_offers, partB_offers = self.tradehandler.list() + count = 1 + partA_offerlist = "" + for offer in partA_offers: + partA_offerlist += "\n {w%i{n %s" % (count, offer.key) + count += 1 + if not partA_offerlist: + partA_offerlist = "\n " + partB_offerlist = "" + for offer in partB_offers: + partB_offerlist += "\n {w%i{n %s" % (count, offer.key) + count += 1 + if not partB_offerlist: + partB_offerlist = "\n " + + string = "{gOffered by %s:{n%s\n{yOffered by %s:{n%s" % (self.partA.key, + partA_offerlist, + self.partB.key, + partB_offerlist) + acceptA = self.tradehandler.partA_accepted and "{gYes{n" or "{rNo{n" + acceptB = self.tradehandler.partB_accepted and "{gYes{n" or "{rNo{n" + string += "\n\n%s agreed: %s, %s agreed: %s" % \ + (self.partA.key, acceptA, self.partB.key, acceptB) + string += "\n Use 'offer', 'eval' and 'accept'/'decline' to trade. See also 'trade help'." + caller.msg(string) + +# finish + +class CmdFinish(CmdTradeBase): + """ + end the trade prematurely + + Usage: + end trade [:say] + finish trade [:say] + + This ends the trade prematurely. No trade will take place. + + """ + key = "end trade" + aliases = "finish trade" + locks = "cmd:all()" + help_category = "Trading" + + def func(self): + "end trade" + caller = self.caller + self.tradehandler.finish(force=True) + caller.msg(self.str_caller % "You {raborted{n trade. No deal was made.") + self.msg_other(caller, self.str_other % "%s {raborted{n trade. No deal was made." % caller.key) + +# custom Trading cmdset + +class CmdsetTrade(CmdSet): + """ + This cmdset is added when trade is initated. It is handled by the + trade event handler. + """ + key = "cmdset_trade" + + def at_cmdset_creation(self): + "Called when cmdset is created" + self.add(CmdTradeHelp()) + self.add(CmdOffer()) + self.add(CmdAccept()) + self.add(CmdDecline()) + self.add(CmdEvaluate()) + self.add(CmdStatus()) + self.add(CmdFinish()) + + +# access command - once both have given this, this will create the trading cmdset +# to start trade. + +class CmdTrade(Command): + """ + Initiate trade with another party + + Usage: + trade [:say] + trade accept [:say] + trade decline [:say] + + Initiate trade with another party. The other party needs to repeat + this command with trade accept/decline within a minute in order to + properly initiate the trade action. You can use the decline option + yourself if you want to retract an already suggested trade. The + optional say part works like the say command and allows you to add + info to your choice. + """ + key = "trade" + aliases = ["barter"] + locks = "cmd:all()" + help_category = "General" + + def func(self): + "Initiate trade" + + if not self.args: + if self.caller.ndb.tradehandler and self.caller.ndb.tradeevent.trade_started: + self.caller.msg("You are already in a trade. Use 'end trade' to abort it.") + else: + self.caller.msg("Usage: trade [accept|decline] [:emote]") + return + self.args = self.args.strip() + + # handle the emote manually here + selfemote = "" + theiremote = "" + if ':' in self.args: + self.args, emote = [part.strip() for part in self.args.rsplit(":", 1)] + selfemote = 'You say, "%s"\n ' % emote + if self.caller.has_player: + theiremote = '{c%s{n says, "%s"\n ' % (self.caller.key, emote) + else: + theiremote = '%s says, "%s"\n ' % (self.caller.key, emote) + + # for the sake of this command, the caller is always partA; this might not + # match the actual name in tradehandler (in the case of using this command + # to accept/decline a trade invitation). + partA = self.caller + accept = 'accept' in self.args + decline = 'decline' in self.args + if accept: + partB = self.args.rstrip('accept').strip() + elif decline: + partB = self.args.rstrip('decline').strip() + else: + partB = self.args + partB = self.caller.search(partB) + if not partB: + return + if partA == partB: + partA.msg("You play trader with yourself.") + return + + # messages + str_initA = "You ask to trade with %s. They need to accept within %s secs." + str_initB = "%s wants to trade with you. Use {wtrade %s accept/decline [:emote]{n to answer (within %s secs)." + str_noinitA = "%s declines the trade" + str_noinitB = "You decline trade with %s." + str_startA = "%s starts to trade with you. See {wtrade help{n for aid." + str_startB = "You start to trade with %s. See {wtrade help{n for aid." + + if not (accept or decline): + # initialization of trade + if self.caller.ndb.tradehandler: + # trying to start trade without stopping a previous one + if self.caller.ndb.tradehandler.trade_started: + string = "You are already in trade with %s. You need to end trade first." + else: + string = "You are already trying to initiate trade with %s. You need to decline that trade first." + self.caller.msg(string % partB.key) + elif partB.ndb.tradehandler and partB.ndb.tradehandler.partB == partA: + # this is equivalent to partA accepting a trade from partB (so roles are reversed) + partB.ndb.tradehandler.join(partA) + partB.msg(theiremote + str_startA % partA.key) + partA.msg(selfemote + str_startB % (partB.key)) + else: + # initiate a new trade + TradeHandler(partA, partB) + partA.msg(selfemote + str_initA % (partB.key, TRADE_TIMEOUT)) + partB.msg(theiremote + str_initB % (partA.key, partA.key, TRADE_TIMEOUT)) + partA.scripts.add(TradeTimeout) + return + elif accept: + # accept a trade proposal from partB (so roles are reversed) + if partA.ndb.tradehandler: + # already in a trade + partA.msg("You are already in trade with %s. You need to end that first." % partB.key) + return + if partB.ndb.tradehandler.join(partA): + partB.msg(theiremote + str_startA % partA.key) + partA.msg(selfemote + str_startB % partB.key) + else: + partA.msg("No trade proposal to accept.") + return + else: + # decline trade proposal from partB (so roles are reversed) + if partA.ndb.tradehandler and partA.ndb.tradehandler.partB == partA: + # stopping an invite + partA.ndb.tradehandler.finish(force=True) + partB.msg(theiremote + "%s aborted trade attempt with you." % partA) + partA.msg(selfemote + "You aborted the trade attempt with %s." % partB) + elif partB.ndb.tradehandler and partB.ndb.tradehandler.unjoin(partA): + partB.msg(theiremote + str_noinitA % partA.key) + partA.msg(selfemote + str_noinitB % partB.key) + else: + partA.msg("No trade proposal to decline.") + return +