diff --git a/src/utils/dummyrunner.py b/src/utils/dummyrunner.py new file mode 100644 index 0000000000..50e3f198e7 --- /dev/null +++ b/src/utils/dummyrunner.py @@ -0,0 +1,253 @@ +""" +Dummy client runner + +This module implements a stand-alone launcher for stress-testing +an Evennia game. It will launch any number of fake clients. These +clients will log into the server and start doing random operations. +Customizing and weighing these operations differently depends on +which type of game is tested. The module contains a testing module +for plain Evennia. + +Please note that you shouldn't run this on a production server! +Launch the program without any arguments or options to see a +full step-by-step setup help. + +Basically (for testing default Evennia): + + - Use an empty/testing database. + - set PERMISSION_PLAYERS_DEFAULT = "Builders" + - start server, eventually with profiling active + - launch this client runner + +If you want to customize the runner's client actions +(because you changed the cmdset or needs to better +match your use cases or add more actions), you can +change which actions by adding a path to + + DUMMYRUNNER_ACTIONS_MODULE = + +in your settings. See utils.dummyrunner_actions.py +for instructions on how to define this module. + +""" + +import os, sys, time, random +from optparse import OptionParser +from twisted.conch import telnet +from twisted.internet import reactor, protocol +# from twisted.application import internet, service +# from twisted.web import client +from twisted.internet.task import LoopingCall + +# Tack on the root evennia directory to the python path and initialize django settings +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +from django.core.management import setup_environ +from game import settings +setup_environ(settings) + +from django.conf import settings +from src.utils import utils + +HELPTEXT = """ +DO NOT RUN THIS ON A PRODUCTION SERVER! USE A CLEAN/TESTING DATABASE! + +This stand-alone program launches dummy telnet clients against a +running Evennia server. The idea is to mimic real players logging in +and repeatedly doing resource-heavy commands so as to stress test the +game. It uses the default command set to log in and issue commands, so +if that was customized, some of the functionality will not be tested +(it will not fail, the commands will just not be recognized). The +running clients will create new objects and rooms all over the place +as part of their running, so using a clean/testing database is +strongly recommended. + +Setup: + 1) setup a fresh/clean database (if using sqlite, just safe-copy + away your real evennia.db3 file and create a new one with + manage.py) + 2) in game/settings.py, add + + PERMISSION_PLAYER_DEFAULT="Builders" + + 3a) Start Evennia like normal. + 3b) If you want profiling, start Evennia like this instead: + + python runner.py -S start + + this will start Evennia under cProfiler with output server.prof. + 4) run this dummy runner: + + python dummyclients.py [timestep] [port] + + Default is to connect one client to port 4000, using a 5 second + timestep. Increase the number of clients and shorten the + timestep (minimum is 1s) to further stress the game. + + You can stop the dummy runner with Ctrl-C. + + 5) Log on and determine if game remains responsive despite the + heavier load. Note that if you do profiling, there is an + additional overhead from the profiler too! + 6) If you use profiling, let the game run long enough to gather + data, then stop the server. You can inspect the server.prof file + from a python prompt (see Python's manual on cProfiler). + +""" +#------------------------------------------------------------ +# Helper functions +#------------------------------------------------------------ + +def idcounter(): + "generates subsequent id numbers" + idcount = 0 + while True: + idcount += 1 + yield idcount +OID = idcounter() +CID = idcounter() + +def makeiter(obj): + "makes everything iterable" + if not hasattr(obj, '__iter__'): + return [obj] + return obj + +#------------------------------------------------------------ +# Client classes +#------------------------------------------------------------ + +class DummyClient(telnet.StatefulTelnetProtocol): + """ + Handles connection to a running Evennia server, + mimicking a real player by sending commands on + a timer. + """ + + def connectionMade(self): + + # public properties + self.cid = CID.next() + self.istep = 0 + self.exits = [] # exit names created + self.objs = [] # obj names created + + self._actions = self.factory.actions + self._echo_brief = self.factory.verbose == 1 + self._echo_all = self.factory.verbose == 2 + print " ** client %i connected successfully." % self.cid + + # start client tick + d = LoopingCall(self.step) + d.start(self.factory.timestep, now=True).addErrback(self.error) + + def dataReceived(self, data): + "Echo incoming data to stdout" + if self._echo_all: + print data + + def connectionLost(self, reason): + "loosing the connection" + print " ** client %i lost connection." % self.cid + + def error(self, err): + "error callback" + print err + + def counter(self): + "produces a unique id, also between clients" + return OID.next() + + def step(self): + """ + Perform a step. This is called repeatedly by the runner + and causes the client to issue commands to the server. + This holds all "intelligence" of the dummy client. + """ + if self.istep == 0: + cfunc = self._actions[0] + else: # random selection using cumulative probabilities + rand = random.random() + cfunc = [func for cprob, func in self._actions[1] if cprob >= rand][0] + # launch the action (don't hide tracebacks) + cmd, report = cfunc(self) + # handle the result + cmd = "\n".join(makeiter(cmd)) + if self._echo_brief or self._echo_all: + print "client %i %s" % (self.cid, report) + self.sendLine(cmd) + self.istep += 1 + +class DummyFactory(protocol.ClientFactory): + protocol = DummyClient + + def __init__(self, actions, timestep, verbose): + "Setup the factory base (shared by all clients)" + self.actions = actions + self.timestep = timestep + self.verbose = verbose + +#------------------------------------------------------------ +# Access method: +# Starts clients and connects them to a running server. +#------------------------------------------------------------ + +def start_all_dummy_clients(actions, nclients=1, timestep=5, telnet_port=4000, verbose=0): + + # validating and preparing the action tuple + + # make sure the probabilities add up to 1 + pratio = 1.0 / sum(tup[0] for tup in actions[1:]) + flogin, probs, cfuncs = actions[0], [tup[0] * pratio for tup in actions[1:]], [tup[1] for tup in actions[1:]] + # create cumulative probabilies for the random actions + cprobs = [sum(v for i,v in enumerate(probs) if i<=k) for k in range(len(probs))] + # rebuild a new, optimized action structure + actions = (flogin, zip(cprobs, cfuncs)) + + # setting up all clients (they are automatically started) + factory = DummyFactory(actions, timestep, verbose) + for i in range(nclients): + reactor.connectTCP("localhost", telnet_port, factory) + # start reactor + reactor.run() + +#------------------------------------------------------------ +# Command line interface +#------------------------------------------------------------ + +if __name__ == '__main__': + + # parsing command line with default vals + parser = OptionParser(usage="%prog [options] [timestep, [port]]", + description="This program requires some preparations to run properly. Start it without any arguments or options for full help.") + parser.add_option('-v', '--verbose', action='store_const', const=1, dest='verbose', + default=0,help="echo brief description of what clients do every timestep.") + parser.add_option('-V', '--very-verbose', action='store_const',const=2, dest='verbose', + default=0,help="echo all client returns to stdout (hint: use only with nclients=1!)") + + options, args = parser.parse_args() + + nargs = len(args) + nclients = 1 + timestep = 5 + port = 4000 + try: + if not args : raise Exception + if nargs > 0: nclients = max(1, int(args[0])) + if nargs > 1: timestep = max(1, int(args[1])) + if nargs > 2: port = int(args[2]) + except Exception: + print HELPTEXT + sys.exit() + + # import the ACTION tuple from a given module + try: + action_modpath = settings.DUMMYRUNNER_ACTIONS_MODULE + except AttributeError: + # use default + action_modpath = "src.utils.dummyrunner_actions" + actions = utils.mod_import(action_modpath, "ACTIONS") + + print "Connecting %i dummy client(s) to port %i using a %i second timestep ... " % (nclients, port, timestep) + start_all_dummy_clients(actions, nclients, timestep, port, + verbose=options.verbose) + print "... dummy client runner finished." diff --git a/src/utils/dummyrunner_actions.py b/src/utils/dummyrunner_actions.py new file mode 100644 index 0000000000..9dd90bde1b --- /dev/null +++ b/src/utils/dummyrunner_actions.py @@ -0,0 +1,148 @@ +""" +These are actions for the dummy client runner, using +the default command set and intended for unmodified Evennia. + +Each client action is defined as a function. The clients +will perform these actions randomly (except the login action). + +Each action-definition function should take one argument- "client", +which is a reference to the client currently performing the action +Use the client object for saving data between actions. + +The client object has the following relevant properties and methods: + cid - unique client id + istep - the current step + exits - an empty list. Can be used to store exit names + objs - an empty list. Can be used to store object names + counter() - get an integer value. This counts up for every call and + is always unique between clients. + +The action-definition function should return the command that the +client should send to the server (as if it was input in a mud client). +It should also return a string detailing the action taken. This string is +used by the "brief verbose" mode of the runner and is prepended by +"Client N " to produce output like "Client 3 is creating objects ..." + +This module *must* also define a variable named ACTIONS. This is a tuple +where the first element is the function object for the action function +to call when the client logs onto the server. The following elements +are 2-tuples (probability, action_func), where probability defines how +common it is for that particular action to happen. The runner will +randomly pick between those functions based on the probability. + +ACTIONS = (login_func, (0.3, func1), (0.1, func2) ... ) + +To change the runner to use your custom ACTION and/or action +definitions, edit settings.py and add + + DUMMYRUNNER_ACTIONS_MODULE = "path.to.your.module" + +""" + +# it's very useful to have a unique id for this run to avoid any risk +# of clashes + +import time +RUNID = time.time() + +# some convenient templates + +START_ROOM = "testing_room_start-%s-%s" % (RUNID, "%i") +ROOM_TEMPLATE = "testing_room_%s-%s" % (RUNID, "%i") +EXIT_TEMPLATE = "exit_%s-%s" % (RUNID, "%i") +OBJ_TEMPLATE = "testing_obj_%s-%s" % (RUNID, "%i") +TOBJ_TEMPLATE = "testing_button_%s-%s" % (RUNID, "%i") +TOBJ_TYPECLASS = "examples.red_button.RedButton" + +# action function definitions + +def c_login(client): + "logins to the game" + cname = "Dummy-%s-%i" % (RUNID, client.cid) + cemail = "%s@dummy.com" % (cname.lower()) + cpwd = "%s-%s" % (RUNID, client.cid) + cmd = ('create "%s" %s %s' % (cname, cemail, cpwd), + 'connect %s %s' % (cemail, cpwd), + '@dig %s' % START_ROOM % client.cid, + '@teleport %s' % START_ROOM % client.cid) + + return cmd, "logs into game as %s ..." % cname + +def c_looks(client): + "looks at various objects" + cmd = ["look %s" % obj for obj in client.objs] + if not cmd: + cmd = ["look %s" % exi for exi in client.exits] + if not cmd: + cmd = "look" + return cmd, "looks ..." + +def c_examines(client): + "examines various objects" + cmd = ["examine %s" % obj for obj in client.objs] + if not cmd: + cmd = ["examine %s" % exi for exi in client.exits] + if not cmd: + cmd = "examine me" + return cmd, "examines objs ..." + +def c_help(client): + "reads help files" + cmd = ('help', + 'help @teleport', + 'help look', + 'help @tunnel', + 'help @dig') + return cmd, "reads help ..." + +def c_digs(client): + "digs a new room, storing exit names on client" + roomname = ROOM_TEMPLATE % client.counter() + exitname1 = EXIT_TEMPLATE % client.counter() + exitname2 = EXIT_TEMPLATE % client.counter() + client.exits.extend([exitname1, exitname2]) + cmd = '@dig %s = %s, %s' % (roomname, exitname1, exitname2) + return cmd, "digs ..." + +def c_creates_obj(client): + "creates normal objects, storing their name on client" + objname = OBJ_TEMPLATE % client.counter() + client.objs.append(objname) + cmd = ('@create %s' % objname, + '@desc %s = "this is a test object' % objname, + '@set %s/testattr = this is a test attribute value.' % objname, + '@set %s/testattr2 = this is a second test attribute.' % objname) + return cmd, "creates obj ..." + +def c_creates_button(client): + "creates example button, storing name on client" + objname = TOBJ_TEMPLATE % client.counter() + client.objs.append(objname) + cmd = ('@create %s:%s' % (objname, TOBJ_TYPECLASS), + '@desc %s = test red button!' % objname) + return cmd, "creates button ..." + +def c_moves(client): + "moves to a previously created room, using the stored exits" + cmd = client.exits # try all exits - finally one will work + if not cmd: cmd = "look" + return cmd, "moves ..." + + +# Action tuple (required) +# +# This is a tuple of client action functions. The first element is the +# function the client should use to log into the game and move to +# STARTROOM . The following elements are 2-tuples of (probability, +# action_function). The probablities should normally sum up to 1, +# otherwise the system will normalize them. +# + +ACTIONS = ( c_login, + (0.2, c_looks), + (0.1, c_examines), + (0.2, c_help), + (0.1, c_digs), + (0.1, c_creates_obj), + #(0.1, c_creates_button), + (0.2, c_moves))