├── docs ├── .gitignore ├── index.rst ├── Makefile └── make.bat ├── setup.cfg ├── .gitattributes ├── MANIFEST.in ├── cwmud ├── core │ ├── __init__.py │ ├── commands │ │ ├── olc │ │ │ ├── __init__.py │ │ │ ├── name.py │ │ │ └── dig.py │ │ ├── items │ │ │ ├── __init__.py │ │ │ ├── get.py │ │ │ ├── drop.py │ │ │ └── inventory.py │ │ ├── immortal │ │ │ ├── __init__.py │ │ │ ├── announce.py │ │ │ └── goto.py │ │ ├── info │ │ │ ├── __init__.py │ │ │ ├── look.py │ │ │ ├── who.py │ │ │ └── time.py │ │ ├── movement │ │ │ ├── __init__.py │ │ │ ├── exits.py │ │ │ ├── secondary.py │ │ │ └── cardinal.py │ │ ├── admin │ │ │ ├── __init__.py │ │ │ ├── commit.py │ │ │ ├── reload.py │ │ │ └── shutdown.py │ │ ├── session │ │ │ ├── __init__.py │ │ │ ├── quit.py │ │ │ └── logout.py │ │ ├── communication │ │ │ ├── __init__.py │ │ │ ├── say.py │ │ │ └── gossip.py │ │ ├── development │ │ │ ├── __init__.py │ │ │ ├── test.py │ │ │ ├── spawn.py │ │ │ └── python.py │ │ └── __init__.py │ ├── messages.py │ ├── utils │ │ ├── decorators.py │ │ ├── exceptions.py │ │ ├── bases.py │ │ └── __init__.py │ ├── const.py │ ├── text.py │ ├── random.py │ ├── protocols │ │ ├── __init__.py │ │ ├── telnet.py │ │ └── websockets.py │ ├── pickle.py │ ├── json.py │ ├── npcs.py │ ├── help.py │ ├── items.py │ ├── logs.py │ ├── channels.py │ ├── cli.py │ ├── players.py │ └── clients.py ├── game │ └── __init__.py ├── contrib │ ├── worldgen │ │ └── __init__.py │ ├── __init__.py │ └── weather │ │ ├── __init__.py │ │ └── patterns.py ├── libs │ └── __init__.py ├── __main__.py ├── __init__.py ├── settings.py └── nanny.py ├── .coveragerc ├── tests ├── game │ └── test_game.py ├── core │ ├── utils │ │ ├── test_decorators.py │ │ ├── test_exceptions.py │ │ └── test_funcs.py │ ├── test_accounts.py │ ├── test_server.py │ ├── test_menus.py │ ├── test_logs.py │ ├── test_world.py │ ├── test_json.py │ ├── test_pickle.py │ ├── test_commands.py │ ├── test_players.py │ └── test_requests.py ├── conftest.py ├── test_nanny.py └── test_meta.py ├── .gitignore ├── Pipfile ├── circle.yml ├── Makefile ├── LICENSE.txt ├── make.bat ├── setup.py ├── scripts ├── testweather.py └── maptools.py ├── STYLE.md └── README.rst /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Docs Building 2 | .build/ 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E731 W503 3 | max-line-length = 120 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Default behavior for users without core.autocrlf set 2 | * text=auto 3 | 4 | # Files that must be converted to LF endings 5 | *.sh text eol=lf 6 | 7 | # Files that must be converted to CRLF endings 8 | *.bat text eol=crlf 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include cwmud/libs/miniboa.LICENSE.TXT 3 | include requirements.txt 4 | include Makefile 5 | 6 | recursive-include tests *.py requirements.txt 7 | 8 | recursive-include docs * 9 | recursive-exclude docs/.build * 10 | -------------------------------------------------------------------------------- /cwmud/core/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Core server modules.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/game/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Game mechanic modules.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | cwmud/libs/* 5 | 6 | [report] 7 | exclude_lines = 8 | pragma: no cover 9 | def __repr__ 10 | raise NotImplementedError 11 | if __name__ == .__main__.: 12 | 13 | [html] 14 | directory = .coverage-html 15 | -------------------------------------------------------------------------------- /cwmud/contrib/worldgen/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Random world generation.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/olc/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """OLC commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/items/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Item commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/contrib/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Contributed and third-party server modules.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/immortal/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Immortal commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/info/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Informative commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/movement/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Movement commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/libs/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Third party libraries packaged with changes.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/admin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Administrative commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/session/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Session control commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/communication/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Communication commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/core/commands/development/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Developmental commands package.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | -------------------------------------------------------------------------------- /cwmud/contrib/weather/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Weather systems.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from . import patterns # noqa 8 | -------------------------------------------------------------------------------- /tests/game/test_game.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Placeholder until there is something to test.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import cwmud.game # noqa 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python cache and bytecode 2 | *.py[cod] 3 | __pycache__/ 4 | 5 | # Development 6 | *~ 7 | .idea/ 8 | .cache/ 9 | .coverage 10 | .coverage-html/ 11 | .tox 12 | .vscode/ 13 | 14 | # Building 15 | dist/ 16 | build/ 17 | *.egg-info/ 18 | 19 | # Deployment 20 | .env/ 21 | .venv/ 22 | data/ 23 | logs/ 24 | dump.rdb 25 | Pipfile.lock 26 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | coverage = "*" 8 | pytest = "*" 9 | pytest-flake8 = "*" 10 | pytest-cov = "*" 11 | ipython = "*" 12 | sphinx = "*" 13 | 14 | [packages] 15 | bcrypt = "*" 16 | noise = "*" 17 | pylru = "*" 18 | redis = "*" 19 | websockets = "*" 20 | 21 | [requires] 22 | python_version = "3.7" 23 | -------------------------------------------------------------------------------- /cwmud/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """The main entry point for server.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .nanny import start_listeners, start_nanny 8 | 9 | if __name__ == "__main__": # pragma: no cover 10 | start_listeners() 11 | start_nanny() 12 | -------------------------------------------------------------------------------- /tests/core/utils/test_decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for support decorators.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import cwmud.core.utils.decorators # To satisfy test coverage. # noqa 8 | 9 | 10 | # There was something here but not anymore! Keeping file for future use. 11 | -------------------------------------------------------------------------------- /tests/core/test_accounts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for account entities.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from cwmud.core.accounts import Account 8 | 9 | 10 | class TestAccounts: 11 | 12 | """A collection of tests for account entities.""" 13 | 14 | account = Account() 15 | -------------------------------------------------------------------------------- /cwmud/core/messages.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Message brokering.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import redis 8 | 9 | 10 | BROKER = redis.StrictRedis(decode_responses=True) 11 | 12 | 13 | def get_pubsub(): 14 | """Return a Redis pubsub connection.""" 15 | return BROKER.pubsub(ignore_subscribe_messages=True) 16 | -------------------------------------------------------------------------------- /cwmud/core/utils/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Support decorators.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | 8 | def patch(cls, method_name=None): 9 | 10 | def _inner(func): 11 | nonlocal method_name 12 | if not method_name: 13 | method_name = func.__name__ 14 | setattr(cls, method_name, func) 15 | 16 | return _inner 17 | -------------------------------------------------------------------------------- /cwmud/core/const.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Global constants.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | 8 | # Session states 9 | STATE_CONNECTED = 0 10 | STATE_LOGIN = 10 11 | STATE_PLAYING = 20 12 | 13 | 14 | # Trust levels 15 | TRUST_JAIL = 0 16 | TRUST_GUEST = 10 17 | TRUST_PLAYER = 20 18 | TRUST_HELPER = 30 19 | TRUST_BUILDER = 40 20 | TRUST_GM = 50 21 | TRUST_ADMIN = 60 22 | TRUST_OWNER = 70 23 | -------------------------------------------------------------------------------- /cwmud/core/commands/items/get.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Get command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class GetCommand(Command): 13 | 14 | """A command to get an item.""" 15 | 16 | def _action(self): 17 | # char = self.session.char 18 | self.session.send("Ok.") 19 | 20 | 21 | CharacterShell.add_verbs(GetCommand, "get") 22 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/python:3.7 6 | - image: circleci/redis 7 | steps: 8 | - checkout 9 | - run: 10 | name: Create virtual environment 11 | command: | 12 | python3 -m pip install pipenv 13 | pipenv install --dev 14 | pipenv install codecov 15 | - run: 16 | name: Run tests 17 | command: | 18 | pipenv run make test-strict 19 | pipenv run make coverage 20 | pipenv run codecov 21 | - store_artifacts: 22 | path: .coverage-html/ 23 | -------------------------------------------------------------------------------- /cwmud/core/commands/development/test.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Test command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class TestCommand(Command): 13 | 14 | """A command to test something.""" 15 | 16 | def _action(self): 17 | self.session.send("Great success!") 18 | 19 | 20 | CharacterShell.add_verbs(TestCommand, "test", truncate=False) 21 | -------------------------------------------------------------------------------- /cwmud/core/commands/session/quit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Quit command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | from ...utils import joins 10 | 11 | 12 | @COMMANDS.register 13 | class QuitCommand(Command): 14 | 15 | """A command for quitting the game.""" 16 | 17 | def _action(self): 18 | self.session.close("Okay, goodbye!", 19 | log_msg=joins(self.session, "has quit.")) 20 | 21 | 22 | CharacterShell.add_verbs(QuitCommand, "quit", truncate=False) 23 | -------------------------------------------------------------------------------- /cwmud/core/commands/immortal/announce.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Announce command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...channels import CHANNELS 9 | from ...characters import CharacterShell 10 | 11 | 12 | @COMMANDS.register 13 | class AnnounceCommand(Command): 14 | 15 | """A command to announce something.""" 16 | 17 | no_parse = True 18 | 19 | def _action(self): 20 | message = self.args[0].strip() 21 | CHANNELS["announce"].send(message) 22 | 23 | 24 | CharacterShell.add_verbs(AnnounceCommand, "announce") 25 | -------------------------------------------------------------------------------- /cwmud/core/commands/admin/commit.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Commit command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | from ...entities import ENTITIES 10 | from ...storage import STORES 11 | 12 | 13 | @COMMANDS.register 14 | class CommitCommand(Command): 15 | 16 | """A command to force a global store commit.""" 17 | 18 | def _action(self): 19 | ENTITIES.save() 20 | STORES.commit() 21 | self.session.send("Ok.") 22 | 23 | 24 | CharacterShell.add_verbs(CommitCommand, "commit", truncate=False) 25 | -------------------------------------------------------------------------------- /cwmud/core/commands/communication/say.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Say command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class SayCommand(Command): 13 | 14 | """A command for room-specific communication.""" 15 | 16 | no_parse = True 17 | 18 | def _action(self): 19 | char = self.session.char 20 | message = self.args[0].strip() 21 | char.act("{s} say{ss}, '{msg}'.", {"msg": message}, 22 | to=char.room.chars) 23 | 24 | 25 | CharacterShell.add_verbs(SayCommand, "say", "'") 26 | -------------------------------------------------------------------------------- /cwmud/core/commands/session/logout.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Logout command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...accounts import AccountMenu 9 | from ...characters import CharacterShell 10 | 11 | 12 | @COMMANDS.register 13 | class LogoutCommand(Command): 14 | 15 | """A command for logging out of the game.""" 16 | 17 | def _action(self): 18 | if self.session.char: 19 | self.session.char.suspend() 20 | self.session.shell = None 21 | self.session.menu = AccountMenu 22 | 23 | 24 | CharacterShell.add_verbs(LogoutCommand, "logout", truncate=False) 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | .DEFAULT_GOAL := nothing 3 | 4 | .PHONY: clean clean-coverage clean-pyc clean-test coverage nothing test tests 5 | 6 | clean: clean-coverage clean-pyc clean-test 7 | 8 | clean-coverage: 9 | rm -f .coverage 10 | rm -rf .coverage-html 11 | 12 | clean-pyc: 13 | find . -type f -and -name "*.pyc" | xargs --no-run-if-empty rm 14 | find . -type d -and -name "__pycache__" | xargs --no-run-if-empty rm -r 15 | 16 | clean-test: 17 | rm -rf .cache 18 | 19 | coverage: clean-coverage clean-test 20 | py.test --cov-report html --cov cwmud tests 21 | 22 | nothing: 23 | 24 | print-coverage: clean-coverage clean-test 25 | py.test --cov cwmud tests 26 | 27 | test: clean-test 28 | py.test --flake8 cwmud tests 29 | 30 | test-strict: clean-test 31 | py.test --flake8 -x --strict cwmud tests 32 | 33 | tests: test 34 | -------------------------------------------------------------------------------- /cwmud/core/commands/development/spawn.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Spawn command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class SpawnCommand(Command): 13 | 14 | """A command to test something.""" 15 | 16 | def _action(self): 17 | from ...entities import ENTITIES 18 | entity = ENTITIES[self.args[0]]() 19 | entity.name = self.args[1] 20 | self.session.char.inventory.append(entity) 21 | self.session.send("Ok.") 22 | 23 | 24 | CharacterShell.add_verbs(SpawnCommand, "spawn", truncate=False) 25 | -------------------------------------------------------------------------------- /cwmud/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A modular MUD server.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2018 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from os.path import abspath, dirname 8 | 9 | 10 | VERSION = (0, 4, 0, 0) 11 | ROOT_DIR = dirname(dirname(abspath(__file__))) 12 | BASE_PACKAGE = __name__ 13 | 14 | 15 | def get_version(): 16 | """Return the version string.""" 17 | return "{}{}".format(".".join([str(n) for n in VERSION[:3]]), 18 | "" if VERSION[3] == 0 else ".dev{}".format(VERSION[3])) 19 | 20 | 21 | __author__ = "Will Hutcheson" 22 | __contact__ = "will@whutch.com" 23 | __homepage__ = "https://github.com/whutch/cwmud" 24 | __license__ = "MIT" 25 | __docformat__ = "restructuredtext" 26 | __version__ = get_version() 27 | -------------------------------------------------------------------------------- /cwmud/core/commands/info/look.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Look command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class LookCommand(Command): 13 | 14 | """A command to allow a character to look at things.""" 15 | 16 | def _action(self): 17 | char = self.session.char 18 | if not char: 19 | self.session.send("You're not playing a character!") 20 | return 21 | if not char.room: 22 | self.session.send("You're not in a room!") 23 | return 24 | char.show_room() 25 | 26 | 27 | CharacterShell.add_verbs(LookCommand, "look", "l") 28 | -------------------------------------------------------------------------------- /cwmud/core/commands/info/who.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Who command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | from ...sessions import SESSIONS 10 | 11 | 12 | @COMMANDS.register 13 | class WhoCommand(Command): 14 | 15 | """A command to display the active players.""" 16 | 17 | def _action(self): 18 | chars = [session.char for session in SESSIONS.all() 19 | if session.char and session.char.active] 20 | self.session.send("Players online:", len(chars)) 21 | for char in chars: 22 | self.session.send(" ^W", char.name, "^~ ", char.title, sep="") 23 | 24 | 25 | CharacterShell.add_verbs(WhoCommand, "who") 26 | -------------------------------------------------------------------------------- /cwmud/core/commands/info/time.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Time command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from datetime import datetime as dt 8 | 9 | from .. import Command, COMMANDS 10 | from ...characters import CharacterShell 11 | from ...timing import TIMERS 12 | 13 | 14 | @COMMANDS.register 15 | class TimeCommand(Command): 16 | 17 | """A command to display the current server time. 18 | 19 | This can be replaced in a game shell to display special in-game time, etc. 20 | 21 | """ 22 | 23 | def _action(self): 24 | timestamp = dt.fromtimestamp(TIMERS.time).strftime("%c") 25 | self.session.send("Current time: ", timestamp, 26 | " (", TIMERS.get_time_code(), ")", sep="") 27 | 28 | 29 | CharacterShell.add_verbs(TimeCommand, "time") 30 | -------------------------------------------------------------------------------- /cwmud/core/commands/olc/name.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Name command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class NameCommand(Command): 13 | 14 | """A command for naming things.""" 15 | 16 | no_parse = True 17 | 18 | def _action(self): 19 | # This will later be a general OLC command for naming anything but 20 | # for now you can just name rooms. 21 | char = self.session.char 22 | if not char.room: 23 | self.session.send("You're not in a room!") 24 | return 25 | char.room.name = self.args[0].strip().title() 26 | self.session.send("Ok.") 27 | 28 | 29 | CharacterShell.add_verbs(NameCommand, "name") 30 | -------------------------------------------------------------------------------- /cwmud/core/commands/movement/exits.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Exits command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class ExitsCommand(Command): 13 | 14 | """A command to display the exits of the room a character is in.""" 15 | 16 | def _action(self): 17 | char = self.session.char 18 | if not char: 19 | self.session.send("You're not playing a character!") 20 | if not char.room: 21 | self.session.send("You're not in a room!") 22 | return 23 | char.show_exits(short=True if self.args 24 | and self.args[0] == "short" else False) 25 | 26 | 27 | CharacterShell.add_verbs(ExitsCommand, "exits", "ex") 28 | -------------------------------------------------------------------------------- /cwmud/core/text.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Text manipulation and processing.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | 8 | # TODO: should every character after a caret be considered a code? 9 | CARET_CODES = [ 10 | "^k", "^K", "^r", "^R", "^g", "^G", "^y", "^Y", "^b", "^B", 11 | "^m", "^M", "^c", "^C", "^w", "^W", "^0", "^1", "^2", "^3", 12 | "^4", "^5", "^6", "^d", "^I", "^i", "^~", "^U", "^u", "^!", 13 | "^.", "^s", "^l"] 14 | 15 | 16 | def strip_caret_codes(text): 17 | """Strip out any caret codes from a string. 18 | 19 | :param str text: The text to strip the codes from 20 | :returns str: The clean text 21 | 22 | """ 23 | text = text.replace("^^", "\0") 24 | for code in CARET_CODES: 25 | text = text.replace(code, "") 26 | return text.replace("\0", "^") 27 | -------------------------------------------------------------------------------- /cwmud/core/commands/admin/reload.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Reload command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...channels import CHANNELS 9 | from ...characters import CharacterShell 10 | from ...server import SERVER 11 | 12 | 13 | @COMMANDS.register 14 | class ReloadCommand(Command): 15 | 16 | """A command to reload the game server, hopefully without interruption. 17 | 18 | This is similar to the old ROM-style copyover, except that we try and 19 | preserve a complete game state rather than just the open connections. 20 | 21 | """ 22 | 23 | def _action(self): 24 | CHANNELS["announce"].send("Server is reloading, please remain calm!") 25 | SERVER.reload() 26 | 27 | 28 | CharacterShell.add_verbs(ReloadCommand, "reload", truncate=False) 29 | -------------------------------------------------------------------------------- /cwmud/core/commands/items/drop.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Drop command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class DropCommand(Command): 13 | 14 | """A command to drop an item.""" 15 | 16 | def _action(self): 17 | char = self.session.char 18 | if not char.room: 19 | self.session.send("You can't drop things here.") 20 | return 21 | for item in char.inventory: 22 | if self.args[0] in item.nouns: 23 | break 24 | else: 25 | self.session.send("You don't have that.") 26 | return 27 | self.session.send("You drop {}.".format(item.name)) 28 | char.inventory.remove(item) 29 | item.delete() 30 | 31 | 32 | CharacterShell.add_verbs(DropCommand, "drop") 33 | -------------------------------------------------------------------------------- /cwmud/core/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Utility exception classes.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | 8 | class AlreadyExists(Exception): 9 | 10 | """Exception for adding an item to a collection it is already in.""" 11 | 12 | def __init__(self, key, old, new=None): 13 | self.key = key 14 | self.old = old 15 | self.new = new 16 | 17 | 18 | class ServerShutdown(Exception): 19 | 20 | """Exception to signal that the server should be shutdown.""" 21 | 22 | def __init__(self, forced=True): 23 | self.forced = forced 24 | 25 | 26 | class ServerReboot(Exception): 27 | 28 | """Exception to signal that the server should be rebooted.""" 29 | 30 | 31 | class ServerReload(Exception): 32 | 33 | """Exception to signal that the server should be reloaded.""" 34 | 35 | def __init__(self, new_pid=None): 36 | self.new_pid = new_pid 37 | -------------------------------------------------------------------------------- /cwmud/core/commands/communication/gossip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Gossip command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...channels import Channel, CHANNELS 9 | from ...characters import Character, CharacterShell 10 | 11 | 12 | GOSSIP = Channel("^M[Gossip]^W {speaker}^w: {msg}^~", logged=True) 13 | CHANNELS.register("gossip", GOSSIP) 14 | 15 | 16 | @COMMANDS.register 17 | class GossipCommand(Command): 18 | 19 | """A command for global communication.""" 20 | 21 | no_parse = True 22 | 23 | def _action(self): 24 | char = self.session.char 25 | message = self.args[0].strip() 26 | sessions = [_char.session for _char in Character.all()] 27 | CHANNELS["gossip"].send(message, context={"speaker": char.name}, 28 | members=sessions) 29 | 30 | 31 | CharacterShell.add_verbs(GossipCommand, "gossip", "\"") 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 Will Hutcheson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cwmud/core/commands/items/inventory.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Inventory command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class InventoryCommand(Command): 13 | 14 | """A command to list a character's inventory.""" 15 | 16 | def _action(self): 17 | char = self.session.char 18 | counts = char.inventory.get_counts() 19 | weight = char.inventory.get_weight() 20 | output = ["You are carrying:\n"] 21 | if counts: 22 | for name, count in sorted(counts): 23 | if count > 1: 24 | output.append("^K(^~{:2}^K)^~ {}".format(count, name)) 25 | else: 26 | output.append(" {}".format(name)) 27 | else: 28 | output.append(" nothing") 29 | output.append("\nWeight: {}/? stones".format(weight)) 30 | self.session.send("\n".join(output)) 31 | 32 | 33 | CharacterShell.add_verbs(InventoryCommand, "inventory", "inv", "in", "i") 34 | -------------------------------------------------------------------------------- /cwmud/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Core settings and configuration.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from os import getcwd 8 | from os.path import join 9 | 10 | 11 | DEBUG = False 12 | TESTING = False 13 | 14 | # General 15 | MUD_NAME = "Clockwork" 16 | MUD_NAME_FULL = "Clockwork MUD Server" 17 | 18 | # Networking 19 | DEFAULT_HOST = "localhost" 20 | DEFAULT_PORT = 4000 21 | IDLE_TIME = 180 # seconds 22 | IDLE_TIME_MAX = 600 # seconds 23 | 24 | # Logging 25 | LOG_PATH = join(getcwd(), "logs", "mud.log") 26 | LOG_TIME_FORMAT_CONSOLE = "%H:%M:%S,%F" 27 | LOG_TIME_FORMAT_FILE = "%Y-%m-%d %a %H:%M:%S,%F" 28 | LOG_ROTATE_WHEN = "midnight" 29 | LOG_ROTATE_INTERVAL = 1 30 | LOG_UTC_TIMES = False 31 | 32 | # Storage 33 | DATA_DIR = join(getcwd(), "data") 34 | 35 | # Optional modules 36 | CONTRIB_MODULES = [ 37 | # These should be import paths relative to the `contrib` package. 38 | # ".my_contrib_module", 39 | ] 40 | GAME_MODULES = [ 41 | # These should be import paths relative to the `game` package. 42 | # ".my_game_module", 43 | ] 44 | 45 | # Advanced 46 | FORCE_GC_COLLECT = False 47 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Clockwork documentation master file, created by 2 | sphinx-quickstart on Sat Oct 25 22:28:12 2014. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Clockwork's documentation! 7 | ===================================== 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | .. automodule:: cwmud.core.commands 15 | :members: 16 | 17 | .. automodule:: cwmud.core.events 18 | :members: 19 | 20 | .. automodule:: cwmud.core.logs 21 | :members: 22 | 23 | .. automodule:: cwmud.core.net 24 | :members: 25 | 26 | .. automodule:: cwmud.core.pickle 27 | :members: 28 | 29 | .. automodule:: cwmud.core.server 30 | :members: 31 | 32 | .. automodule:: cwmud.core.sessions 33 | :members: 34 | 35 | .. automodule:: cwmud.core.shells 36 | :members: 37 | 38 | .. automodule:: cwmud.core.storage 39 | :members: 40 | 41 | .. automodule:: cwmud.core.timing 42 | :members: 43 | 44 | .. automodule:: cwmud.core.utils.exceptions 45 | :members: 46 | 47 | .. automodule:: cwmud.core.utils.funcs 48 | :members: 49 | 50 | .. automodule:: cwmud.core.utils.mixins 51 | :members: 52 | 53 | Indices and tables 54 | ================== 55 | 56 | * :ref:`genindex` 57 | * :ref:`modindex` 58 | * :ref:`search` 59 | 60 | -------------------------------------------------------------------------------- /cwmud/core/commands/immortal/goto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Goto command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | from ...world import Room 10 | 11 | 12 | @COMMANDS.register 13 | class GotoCommand(Command): 14 | 15 | """A command to teleport to somewhere else.""" 16 | 17 | def _action(self): 18 | try: 19 | coords = self.args[0].split(",") 20 | if len(coords) == 2: 21 | coords.append("0") 22 | elif len(coords) != 3: 23 | raise IndexError 24 | x, y, z = map(int, coords) 25 | room = Room.get(x=x, y=y, z=z) 26 | if not room: 27 | self.session.send("That doesn't seem to be a place.") 28 | return 29 | poof_out = "{s} disappear{ss} in a puff of smoke." 30 | poof_in = "{s} arrive{ss} in a puff of smoke." 31 | self.session.char.move_to_room(room, poof_out, poof_in) 32 | except IndexError: 33 | self.session.send("Syntax: goto (x),(y)[,z]") 34 | 35 | 36 | CharacterShell.add_verbs(GotoCommand, "goto", truncate=False) 37 | -------------------------------------------------------------------------------- /tests/core/test_server.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for server initialization and loop logic.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import pytest 8 | 9 | from cwmud.core.server import EVENTS, SERVER 10 | 11 | 12 | # We need a dummy pid. 13 | if SERVER._pid is None: 14 | SERVER._pid = 0 15 | 16 | 17 | def test_boot(): 18 | 19 | """Test that we can initiate and boot the server.""" 20 | 21 | array = [] 22 | 23 | # This one should not fire, as init is not pre-hookable. 24 | @EVENTS.hook("server_init", pre=True) 25 | def _init_pre_hook(): 26 | array.append(0) 27 | 28 | @EVENTS.hook("server_init") 29 | def _init_post_hook_1(): 30 | array.append(1) 31 | 32 | @EVENTS.hook("server_boot") 33 | def _init_post_hook_2(): 34 | array.append(2) 35 | 36 | SERVER.boot() 37 | assert array == [1, 2] 38 | 39 | 40 | def test_loop(): 41 | 42 | """Test that we can loop through the server.""" 43 | 44 | class _DummyException(Exception): 45 | pass 46 | 47 | @EVENTS.hook("server_loop") 48 | def _loop_hook(): 49 | raise _DummyException() 50 | 51 | with pytest.raises(_DummyException): 52 | SERVER.loop() 53 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Configuration for testing through py.test.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from os.path import abspath, dirname, join 8 | import sys 9 | import time 10 | 11 | import redis 12 | 13 | 14 | TEST_ROOT = dirname(abspath(__file__)) 15 | sys.path.insert(0, dirname(TEST_ROOT)) 16 | 17 | from cwmud import ROOT_DIR, settings # noqa 18 | 19 | 20 | settings.TESTING = True 21 | 22 | # Change the log path during testing. 23 | settings.LOG_PATH = join(ROOT_DIR, ".pytest_cache", "logs", "test.log") 24 | 25 | # Change the data path during testing. 26 | settings.DATA_DIR = join(ROOT_DIR, ".pytest_cache", "data") 27 | 28 | settings.DEFAULT_HOST = "localhost" 29 | # Use a different listen port, in case the tests are run while a 30 | # real server is running on the same system. 31 | settings.DEFAULT_PORT = 4445 32 | 33 | # Make sure the idle times are defaults 34 | settings.IDLE_TIME = 180 35 | settings.IDLE_TIME_MAX = 600 36 | 37 | # Clear out the contrib modules 38 | settings.INCLUDE_MODULES = [] 39 | 40 | 41 | # This needs to be imported after the settings are updated. 42 | from cwmud.core.logs import get_logger # noqa 43 | 44 | log = get_logger("tests") 45 | 46 | 47 | # Send out a message to signal the starts of a test run. 48 | rdb = redis.StrictRedis(decode_responses=True) 49 | rdb.publish("tests-start", time.time()) 50 | log.debug("====== TESTS START ======") 51 | -------------------------------------------------------------------------------- /cwmud/core/commands/olc/dig.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Dig command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | from ...world import Room 10 | 11 | 12 | @COMMANDS.register 13 | class DigCommand(Command): 14 | 15 | """A command for creating new rooms.""" 16 | 17 | _dirs = { 18 | "east": (1, 0, 0), 19 | "west": (-1, 0, 0), 20 | "north": (0, 1, 0), 21 | "south": (0, -1, 0), 22 | "up": (0, 0, 1), 23 | "down": (0, 0, -1), 24 | } 25 | 26 | def _action(self): 27 | char = self.session.char 28 | if not char.room: 29 | self.session.send("You're not in a room!") 30 | return 31 | for dir_name, change in self._dirs.items(): 32 | if dir_name.startswith(self.args[0]): 33 | break 34 | else: 35 | self.session.send("That's not a direction.") 36 | return 37 | x, y, z = map(sum, zip(char.room.coords, change)) 38 | room = Room.find(x=x, y=y, z=z) 39 | if room: 40 | self.session.send("There's already a room over there!") 41 | return 42 | room = Room() 43 | room.x, room.y, room.z = x, y, z 44 | room.save() 45 | char.move_to_room(room, "{s} tunnel{ss} out a new room to the {dir}!", 46 | depart_context={"dir": dir_name}) 47 | 48 | 49 | CharacterShell.add_verbs(DigCommand, "dig") 50 | -------------------------------------------------------------------------------- /cwmud/core/commands/development/python.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Python command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class PyCommand(Command): 13 | 14 | """A command for executing Python code.""" 15 | 16 | no_parse = True 17 | 18 | def _action(self): 19 | # this is super dangerous don't commit this!! 20 | if not self.args[0]: 21 | self.session.send("No code to execute.") 22 | return 23 | import os # noqa 24 | import re # noqa 25 | import sys # noqa 26 | from ...accounts import Account # noqa 27 | from ...entities import ENTITIES, Entity, Unset # noqa 28 | from ...storage import STORES # noqa 29 | from ...shells import SHELLS # noqa 30 | from ...commands import COMMANDS # noqa 31 | from ...server import SERVER # noqa 32 | from ...world import Room # noqa 33 | char = self.session.char # noqa 34 | s = self.session.send # noqa 35 | try: 36 | code = compile(self.args[0][1:].strip('"') + "\n", 37 | "", "exec") 38 | result = exec(code) or "Ok." 39 | self.session.send(result) 40 | except Exception as exc: 41 | self.session.send(type(exc).__name__, ": ", exc.args[0], sep="") 42 | 43 | 44 | CharacterShell.add_verbs(PyCommand, "py", "python", truncate=False) 45 | -------------------------------------------------------------------------------- /cwmud/core/commands/admin/shutdown.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Shutdown command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...channels import CHANNELS 9 | from ...characters import CharacterShell 10 | from ...server import SERVER 11 | from ...timing import duration_to_pulses, PULSE_PER_SECOND, TIMERS 12 | 13 | 14 | @COMMANDS.register 15 | class ShutdownCommand(Command): 16 | 17 | """A command to shutdown the game server.""" 18 | 19 | def _action(self): 20 | arg = self.args[0].lower() if self.args else None 21 | if arg and arg in ("stop", "cancel"): 22 | if "shutdown" not in TIMERS: 23 | self.session.send("There is no shutdown in progress.") 24 | else: 25 | TIMERS.kill("shutdown") 26 | CHANNELS["announce"].send("Shutdown canceled.") 27 | return 28 | try: 29 | if arg is None: 30 | # Default to 10 seconds. 31 | when = duration_to_pulses("10s") 32 | else: 33 | when = duration_to_pulses(self.args[0]) 34 | except ValueError: 35 | self.session.send("Invalid time until shutdown.") 36 | else: 37 | if when: 38 | 39 | CHANNELS["announce"].send( 40 | "Shutdown initiated in", 41 | when // PULSE_PER_SECOND, "seconds!") 42 | 43 | @TIMERS.create(when, "shutdown") 44 | def _shutdown(): 45 | CHANNELS["announce"].send("Server is shutting down!") 46 | SERVER.shutdown() 47 | 48 | 49 | CharacterShell.add_verbs(ShutdownCommand, "shutdown", truncate=False) 50 | -------------------------------------------------------------------------------- /cwmud/core/random.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Random data generation.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import noise 8 | 9 | 10 | def generate_noise(x, y=0, z=None, w=None, scale=1, offset_x=0.0, offset_y=0.0, 11 | octaves=1, persistence=0.5, lacunarity=2.0): 12 | """Generate simplex noise. 13 | 14 | :param x: The x coordinate of the noise value 15 | :param y: The y coordinate of the noise value 16 | :param z: The z coordinate of the noise value 17 | :param w: A fourth dimensional coordinate 18 | :param scale: The scale of the base plane 19 | :param float offset_x: How much to offset `x` by on the base plane 20 | :param float offset_y: How much to offset `y` by on the base plane 21 | :param int octaves: The number of passes to make calculating noise 22 | :param float persistence: The amplitude multiplier per octave 23 | :param float lacunarity: The frequency multiplier per octave 24 | 25 | """ 26 | x = (x + offset_x) / scale 27 | y = (y + offset_y) / scale 28 | if z is not None: 29 | z /= scale 30 | if w is not None: 31 | w /= scale 32 | if z is None and w is None: 33 | return noise.snoise2(x, y, octaves=octaves, 34 | lacunarity=lacunarity, 35 | persistence=persistence) 36 | elif w is None: 37 | return noise.snoise3(x, y, z, octaves=octaves, 38 | lacunarity=lacunarity, 39 | persistence=persistence) 40 | else: 41 | return noise.snoise4(x, y, z, w, octaves=octaves, 42 | lacunarity=lacunarity, 43 | persistence=persistence) 44 | -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | IF /I "%1" == "clean" ( 4 | CALL :clean-coverage 5 | CALL :clean-pyc 6 | CALL :clean-test 7 | ) 8 | 9 | IF /I "%1" == "clean-coverage" ( 10 | CALL :clean-coverage 11 | ) 12 | 13 | IF /I "%1" == "clean-pyc" ( 14 | CALL :clean-pyc 15 | ) 16 | 17 | IF /I "%1" == "clean-test" ( 18 | CALL :clean-test 19 | ) 20 | 21 | IF /I "%1" == "coverage" ( 22 | CALL :coverage 23 | ) 24 | 25 | IF /I "%1" == "print-coverage" ( 26 | CALL :print-coverage 27 | ) 28 | 29 | IF /I "%1" == "test" ( 30 | CALL :test 31 | ) 32 | 33 | IF /I "%1" == "test-strict" ( 34 | CALL :test-strict 35 | ) 36 | 37 | IF /I "%1" == "tests" ( 38 | CALL :test 39 | ) 40 | 41 | EXIT /B %ERRORLEVEL% 42 | 43 | 44 | :clean-coverage 45 | ECHO Cleaning coverage data. 46 | IF EXIST .coverage DEL /F/S/Q .coverage >NUL 47 | IF EXIST .coverage-html RD /S/Q .coverage-html 48 | EXIT /B 0 49 | 50 | 51 | :clean-pyc 52 | ECHO Cleaning cached bytecode. 53 | FOR /R %%d IN (__pycache__) DO IF EXIST %%d (RD /S/Q "%%d") 54 | DEL /F/S/Q .\*.pyc 2>NUL 55 | EXIT /B 0 56 | 57 | 58 | :clean-test 59 | ECHO Cleaning test data. 60 | IF EXIST .pytest_cache RD /S/Q .pytest_cache 61 | EXIT /B 0 62 | 63 | 64 | :coverage 65 | CALL :clean-coverage 66 | CALL :clean-test 67 | ECHO Generating coverage data. 68 | py.test --cov-report html --cov cwmud tests 69 | EXIT /B 0 70 | 71 | 72 | :print-coverage 73 | CALL :clean-coverage 74 | CALL :clean-test 75 | ECHO Printing coverage. 76 | py.test --cov cwmud tests 77 | EXIT /B 0 78 | 79 | 80 | :test 81 | CALL :clean-test 82 | ECHO Running tests. 83 | py.test --flake8 cwmud tests 84 | EXIT /B 0 85 | 86 | 87 | :test-strict 88 | CALL :clean-test 89 | ECHO Running strict tests. 90 | py.test --flake8 -x --strict cwmud tests 91 | EXIT /B 0 92 | -------------------------------------------------------------------------------- /tests/core/utils/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for utility exception classes.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from cwmud.core.utils import exceptions 8 | 9 | 10 | def test_exception_already_exists(): 11 | """Test that raising AlreadyExists works as intended.""" 12 | try: 13 | raise exceptions.AlreadyExists("test", old=1, new=2) 14 | except exceptions.AlreadyExists as exc: 15 | assert exc.key and exc.old and exc.new 16 | 17 | 18 | def test_exception_server_shutdown(): 19 | """Test that raising ServerShutdown works as intended. 20 | 21 | The logic of what raising this does to the server is handled in server.py 22 | and thus tested in test_server.py, not here. 23 | 24 | """ 25 | try: 26 | raise exceptions.ServerShutdown() 27 | except exceptions.ServerShutdown as exc: 28 | assert exc.forced is True 29 | try: 30 | raise exceptions.ServerShutdown(forced=False) 31 | except exceptions.ServerShutdown as exc: 32 | assert exc.forced is False 33 | 34 | 35 | def test_exception_server_reboot(): 36 | """Test that raising ServerReboot works as intended. 37 | 38 | The logic of what raising this does to the server is handled in server.py 39 | and thus tested in test_server.py, not here. 40 | 41 | """ 42 | try: 43 | raise exceptions.ServerReboot() 44 | except exceptions.ServerReboot: 45 | pass 46 | 47 | 48 | def test_exception_server_reload(): 49 | """Test that raising ServerReload works as intended. 50 | 51 | The logic of what raising this does to the server is handled in server.py 52 | and __main__.py, and thus tested in test_server.py and test_main.py, 53 | not here. 54 | 55 | """ 56 | try: 57 | raise exceptions.ServerReload(1) 58 | except exceptions.ServerReload as exc: 59 | assert exc.new_pid == 1 60 | -------------------------------------------------------------------------------- /tests/test_nanny.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the server's main entry point.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from importlib import reload 8 | 9 | import redis 10 | 11 | import cwmud.nanny as nanny 12 | 13 | 14 | class xTestMain: 15 | 16 | """A collection of tests for the server's nanny process.""" 17 | 18 | rdb = redis.StrictRedis(decode_responses=True) 19 | listeners = [] 20 | 21 | @classmethod 22 | def setup_class(cls): 23 | cls.listeners = nanny.start_listeners() 24 | 25 | @classmethod 26 | def teardown_class(cls): 27 | for listener in cls.listeners: 28 | listener.terminate() 29 | 30 | def test_main(self): 31 | 32 | """Test that the nanny process runs properly.""" 33 | 34 | channels = self.rdb.pubsub(ignore_subscribe_messages=True) 35 | 36 | def _server_booted(msg): 37 | pid = int(msg["data"]) 38 | self.rdb.publish("server-shutdown", pid) 39 | 40 | channels.subscribe(**{"server-boot-complete": _server_booted}) 41 | worker = channels.run_in_thread() 42 | nanny.start_nanny() 43 | worker.stop() 44 | reload(nanny) 45 | 46 | def test_main_reload(self): 47 | 48 | """Test that the nanny process can handle a reload request.""" 49 | 50 | channels = self.rdb.pubsub(ignore_subscribe_messages=True) 51 | did_reload = False 52 | 53 | def _server_booted(msg): 54 | nonlocal did_reload 55 | pid = int(msg["data"]) 56 | if did_reload: 57 | self.rdb.publish("server-shutdown", pid) 58 | else: 59 | did_reload = True 60 | self.rdb.publish("server-reload-request", pid) 61 | 62 | channels.subscribe(**{"server-boot-complete": _server_booted}) 63 | worker = channels.run_in_thread() 64 | nanny.start_nanny() 65 | worker.stop() 66 | reload(nanny) 67 | -------------------------------------------------------------------------------- /cwmud/core/utils/bases.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Base classes, abstract or otherwise.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from collections import OrderedDict 8 | 9 | from .exceptions import AlreadyExists 10 | 11 | 12 | class Manager: 13 | 14 | """A collection registration manager.""" 15 | 16 | _managed_type = None # The type of object this manager registers. 17 | _instances = True # Whether instances or classes are registered. 18 | 19 | def __init__(self): 20 | """Create a new data store manager.""" 21 | self._items = OrderedDict() 22 | 23 | def __contains__(self, key): 24 | return key in self._items 25 | 26 | def __getitem__(self, key): 27 | return self._items[key] 28 | 29 | def register(self, key, item): 30 | """Register an object with this manager. 31 | 32 | :param hashable key: The key to register the item under 33 | :param item: The item to be registered 34 | :returns: The registered item 35 | :raises AlreadyExists: If an item is already registered with `key` 36 | :raises TypeError: If `item` is not an instance or subclass of 37 | `managed_type`, depending on the value of 38 | the `instances` class attribute 39 | 40 | """ 41 | if self._instances: 42 | if not isinstance(item, self._managed_type): 43 | raise TypeError("can't register: {} is not an instance of {}" 44 | .format(item, self._managed_type)) 45 | else: 46 | if (not isinstance(item, type) 47 | or not issubclass(item, self._managed_type)): 48 | raise TypeError("can't register: {} is not a subclass of {}" 49 | .format(item, self._managed_type)) 50 | if key in self._items: 51 | raise AlreadyExists(key, self._items[key], item) 52 | self._items[key] = item 53 | return item 54 | -------------------------------------------------------------------------------- /tests/test_meta.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for the whole MUD process via a client.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from importlib import reload 8 | from telnetlib import Telnet 9 | 10 | import redis 11 | 12 | from cwmud import settings 13 | import cwmud.nanny as nanny 14 | 15 | 16 | class xTestMain: 17 | 18 | """A collection of tests for the server's nanny process.""" 19 | 20 | client = Telnet() 21 | rdb = redis.StrictRedis(decode_responses=True) 22 | listeners = [] 23 | 24 | @classmethod 25 | def setup_class(cls): 26 | cls.listeners = nanny.start_listeners() 27 | 28 | @classmethod 29 | def teardown_class(cls): 30 | for listener in cls.listeners: 31 | listener.terminate() 32 | 33 | def test_meta(self): 34 | 35 | """Test several client interactions.""" 36 | 37 | channels = self.rdb.pubsub(ignore_subscribe_messages=True) 38 | 39 | def _server_booted(msg): 40 | # We have to unsubscribe from the server-boot-complete event 41 | # so reloading doesn't loop forever. 42 | channels.unsubscribe("server-boot-complete") 43 | # Connect to the server. 44 | self.client.open(settings.DEFAULT_HOST, settings.DEFAULT_PORT) 45 | # Create a new account. 46 | self.client.write(b"c\ntest@account.com\ntest@account.com\n") 47 | self.client.write(b"testaccount\nyes\n") 48 | self.client.write(b"testpassword\ntestpassword\nno\nyes\n") 49 | # Create a new character and log into it. 50 | self.client.write(b"c\ntestchar\nyes\n1\n") 51 | # Do some stuff. 52 | self.client.write(b"look\n") 53 | # Shutdown. 54 | self.client.write(b"shutdown now\n") 55 | 56 | channels.subscribe(**{"server-boot-complete": _server_booted}) 57 | worker = channels.run_in_thread() 58 | nanny.start_nanny() 59 | worker.stop() 60 | reload(nanny) 61 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Setup and distribution.""" 4 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 5 | # :copyright: (c) 2008 - 2017 Will Hutcheson 6 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 7 | 8 | import os 9 | from os.path import abspath, dirname 10 | from setuptools import find_packages, setup 11 | import sys 12 | 13 | PROJECT_ROOT = dirname(abspath(__file__)) 14 | sys.path.insert(0, PROJECT_ROOT) 15 | os.chdir(PROJECT_ROOT) 16 | 17 | from cwmud import __author__, __contact__, __homepage__, get_version 18 | 19 | 20 | def get_reqs(path): 21 | """Parse a pip requirements file. 22 | 23 | :param str path: The path to the requirements file 24 | :returns list: A list of package strings 25 | 26 | """ 27 | reqs = [] 28 | with open(path) as req_file: 29 | for line in req_file: 30 | # Remove any comments 31 | line = line.split("#", 1)[0].strip() 32 | if line and not line.startswith("-"): 33 | reqs.append(line) 34 | return reqs 35 | 36 | 37 | setup( 38 | name="cwmud", 39 | version=get_version(), 40 | # PyPI metadata 41 | description="Clockwork MUD server", 42 | long_description=open("README.rst").read(), 43 | author=__author__, 44 | author_email=__contact__, 45 | url=__homepage__, 46 | license="MIT", 47 | keywords=["clockwork", "mud", "mux", "moo", "mush"], 48 | classifiers=[ 49 | "Development Status :: 3 - Alpha", 50 | "Environment :: Console", 51 | "Intended Audience :: Developers", 52 | "License :: OSI Approved :: MIT License", 53 | "Natural Language :: English", 54 | "Operating System :: OS Independent", 55 | "Programming Language :: Python", 56 | "Programming Language :: Python :: 3.4", 57 | "Topic :: Games/Entertainment :: Multi-User Dungeons (MUD)", 58 | "Topic :: Software Development :: Libraries :: Python Modules", 59 | ], 60 | # Packaging 61 | packages=find_packages(), 62 | zip_safe=False, 63 | # Requirements 64 | install_requires=get_reqs("requirements.txt"), 65 | ) 66 | -------------------------------------------------------------------------------- /cwmud/core/commands/movement/secondary.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Secondary movement commands.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class NortheastCommand(Command): 13 | 14 | """A command to allow a character to move northeast.""" 15 | 16 | def _action(self): 17 | char = self.session.char 18 | if not char: 19 | self.session.send("You're not playing a character!") 20 | return 21 | if not char.room: 22 | self.session.send("You're not in a room!") 23 | return 24 | char.move_direction(x=1, y=1) 25 | 26 | 27 | @COMMANDS.register 28 | class NorthwestCommand(Command): 29 | 30 | """A command to allow a character to move northwest.""" 31 | 32 | def _action(self): 33 | char = self.session.char 34 | if not char: 35 | self.session.send("You're not playing a character!") 36 | return 37 | if not char.room: 38 | self.session.send("You're not in a room!") 39 | return 40 | char.move_direction(x=-1, y=1) 41 | 42 | 43 | @COMMANDS.register 44 | class SoutheastCommand(Command): 45 | 46 | """A command to allow a character to move southeast.""" 47 | 48 | def _action(self): 49 | char = self.session.char 50 | if not char: 51 | self.session.send("You're not playing a character!") 52 | return 53 | if not char.room: 54 | self.session.send("You're not in a room!") 55 | return 56 | char.move_direction(x=1, y=-1) 57 | 58 | 59 | @COMMANDS.register 60 | class SouthwestCommand(Command): 61 | 62 | """A command to allow a character to move southwest.""" 63 | 64 | def _action(self): 65 | char = self.session.char 66 | if not char: 67 | self.session.send("You're not playing a character!") 68 | return 69 | if not char.room: 70 | self.session.send("You're not in a room!") 71 | return 72 | char.move_direction(x=-1, y=-1) 73 | 74 | 75 | CharacterShell.add_verbs(NortheastCommand, "northeast", "ne") 76 | CharacterShell.add_verbs(NorthwestCommand, "northwest", "nw") 77 | CharacterShell.add_verbs(SoutheastCommand, "southeast", "se") 78 | CharacterShell.add_verbs(SouthwestCommand, "southwest", "sw") 79 | -------------------------------------------------------------------------------- /tests/core/test_menus.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for text menus.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import pytest 8 | 9 | from cwmud.core.menus import AlreadyExists, Menu, MenuManager 10 | from cwmud.core.utils import joins 11 | 12 | 13 | class TestMenus: 14 | 15 | """A collection of tests for text menus.""" 16 | 17 | menus = None 18 | menu_class = None 19 | menu = None 20 | 21 | class _FakeSession: 22 | 23 | def __init__(self): 24 | self._output = [] 25 | 26 | def send(self, data, *more, sep=" ", end="\n"): 27 | return self._output.append(joins(data, *more, sep=sep) + end) 28 | 29 | session = _FakeSession() 30 | 31 | def test_menu_manager_create(self): 32 | """Test that we can create a menu manager. 33 | 34 | This is currently redundant, importing the menus package already 35 | creates one, but we can keep it for symmetry and in case that 36 | isn't always so. 37 | 38 | """ 39 | type(self).menus = MenuManager() 40 | assert self.menus 41 | 42 | def test_menu_manager_register(self): 43 | 44 | """Test that we can register a new menu through a manager.""" 45 | 46 | @self.menus.register 47 | class TestMenu(Menu): 48 | """A test menu.""" 49 | 50 | type(self).menu_class = TestMenu 51 | assert "TestMenu" in self.menus._menus 52 | 53 | def test_menu_manager_register_already_exists(self): 54 | """Test that trying to re-register a menu fails.""" 55 | with pytest.raises(AlreadyExists): 56 | self.menus.register(self.menu_class) 57 | 58 | def test_menu_manager_register_not_menu(self): 59 | """Test that trying to register a non-menu fails.""" 60 | with pytest.raises(TypeError): 61 | self.menus.register(object()) 62 | 63 | def test_menu_manager_contains(self): 64 | """Test that we can see if a menu manager contains a menu.""" 65 | assert "TestMenu" in self.menus 66 | assert "SomeNonExistentMenu" not in self.menus 67 | 68 | def test_menu_manager_get_menu(self): 69 | """Test that we can get a menu from a menu manager.""" 70 | assert self.menus["TestMenu"] is self.menu_class 71 | with pytest.raises(KeyError): 72 | self.menus["SomeNonExistentMenu"].resolve("yeah") 73 | 74 | def test_menu_create(self): 75 | """Test that we can create a new menu instance.""" 76 | type(self).menu = self.menu_class(self.session) 77 | assert self.menu 78 | -------------------------------------------------------------------------------- /cwmud/core/protocols/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Transport protocol implementations.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from time import sleep 8 | 9 | from ..logs import get_logger 10 | from ..messages import get_pubsub 11 | 12 | 13 | log = get_logger("protocols") 14 | 15 | 16 | class ProtocolServer: 17 | 18 | """A server for a specific transport protocol. 19 | 20 | This is an abstract base class. 21 | 22 | """ 23 | 24 | def __init__(self): 25 | """Create a new server.""" 26 | self._handlers = set() 27 | self._started = False 28 | 29 | @property 30 | def is_started(self): 31 | """Return whether the server is started or not.""" 32 | return self._started 33 | 34 | def get_handler(self, uid): 35 | """Find a handler by its UID. 36 | 37 | :param uid: The UID to search for 38 | :returns WebSocketHandler: The found handler or None 39 | 40 | """ 41 | for handler in self._handlers: 42 | if handler.uid == uid: 43 | return handler 44 | 45 | def start(self): 46 | """Start the server.""" 47 | self._started = True 48 | 49 | def stop(self): 50 | """Stop the server.""" 51 | self._started = False 52 | 53 | def poll(self): 54 | """Poll the server to process any queued IO.""" 55 | raise NotImplementedError 56 | 57 | def serve(self): 58 | """Continuously serve protocol IO. 59 | 60 | This should be a blocking function that runs until the 61 | server is stopped. 62 | 63 | """ 64 | if not self.is_started: 65 | self.start() 66 | try: 67 | while self.is_started: 68 | self.poll() 69 | sleep(0.025) 70 | except KeyboardInterrupt: 71 | pass 72 | finally: 73 | self.stop() 74 | 75 | 76 | class ProtocolHandler: 77 | 78 | """A client handler for a specific transport protocol. 79 | 80 | This is an abstract base class. 81 | 82 | """ 83 | 84 | def __init__(self, uid=None): 85 | """Create a new client handler.""" 86 | self._messages = get_pubsub() 87 | self._uid = uid 88 | 89 | @property 90 | def uid(self): 91 | """Return a unique identifier for this client.""" 92 | return self._uid 93 | 94 | @property 95 | def alive(self): 96 | """Return whether this handler's client is alive or not.""" 97 | return False 98 | 99 | def poll(self): 100 | """Poll this handler to process any queued IO.""" 101 | raise NotImplementedError 102 | -------------------------------------------------------------------------------- /cwmud/core/pickle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Pickle serialization and storage.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from os import listdir, makedirs, remove 8 | from os.path import abspath, exists, join, splitext 9 | import pickle 10 | 11 | from .. import settings 12 | from .storage import DataStore 13 | from .utils import joins 14 | 15 | 16 | class PickleStore(DataStore): 17 | 18 | """A store that pickles its data.""" 19 | 20 | _opens = False 21 | 22 | def __init__(self, subpath): 23 | """Create a new pickle store.""" 24 | super().__init__() 25 | self._path = join(settings.DATA_DIR, "pickle", subpath) 26 | # Make sure the path to the pickle store exists. 27 | if not exists(self._path): 28 | makedirs(self._path) 29 | 30 | def _get_key_path(self, key): 31 | """Validate and return an absolute path for a pickle file. 32 | 33 | :param str key: The key to store the data under 34 | :returns str: An absolute path to the pickle file for that key 35 | :raises OSError: If the path is not under this store's base path 36 | :raises TypeError: If the key is not a string 37 | 38 | """ 39 | if not isinstance(key, str): 40 | raise TypeError("pickle keys must be strings") 41 | path = abspath(join(self._path, key + ".pkl")) 42 | if not path.startswith(abspath(self._path)): 43 | raise OSError(joins("invalid path to pickle file:", path)) 44 | return path 45 | 46 | def _is_open(self): # pragma: no cover 47 | return True 48 | 49 | def _open(self): # pragma: no cover 50 | pass 51 | 52 | def _close(self): # pragma: no cover 53 | pass 54 | 55 | def _keys(self): 56 | """Return an iterator through the pickle files in this store.""" 57 | for name in listdir(abspath(self._path)): 58 | key, ext = splitext(name) 59 | if ext == ".pkl": 60 | yield key 61 | 62 | def _has(self, key): 63 | """Return whether a pickle file exists or not.""" 64 | path = self._get_key_path(key) 65 | return exists(path) 66 | 67 | def _get(self, key): 68 | """Fetch the data from a pickle file.""" 69 | path = self._get_key_path(key) 70 | with open(path, "rb") as pickle_file: 71 | return pickle.load(pickle_file) 72 | 73 | def _put(self, key, data): 74 | """Store data in a pickle file.""" 75 | path = self._get_key_path(key) 76 | with open(path, "wb") as pickle_file: 77 | pickle.dump(data, pickle_file) 78 | 79 | def _delete(self, key): 80 | """Delete a pickle file.""" 81 | path = self._get_key_path(key) 82 | remove(path) 83 | -------------------------------------------------------------------------------- /tests/core/test_logs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for logging.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from datetime import datetime 8 | import logging 9 | import uuid 10 | 11 | from cwmud import settings 12 | from cwmud.core.logs import _Formatter, get_logger 13 | 14 | 15 | class TestLogs: 16 | 17 | """A collection of tests for logging.""" 18 | 19 | log = None 20 | 21 | def test_logger_create(self): 22 | """Test that our get_logger function returns a Logger instance.""" 23 | type(self).log = get_logger("tests") 24 | assert isinstance(self.log, logging.Logger) 25 | 26 | def test_logger_write(self): 27 | """Test that a Logger writes to our log file.""" 28 | test_uuid = str(uuid.uuid1()) 29 | self.log.debug("Log test - %s", test_uuid) 30 | with open(settings.LOG_PATH) as log_file: 31 | last_line = log_file.readlines()[-1] 32 | assert last_line.rstrip().endswith(test_uuid) 33 | 34 | 35 | class TestFormatter: 36 | 37 | """A collection of tests for our log formatter.""" 38 | 39 | record = None 40 | formatter = _Formatter() 41 | # The console and file formats are hard-coded here instead of pulled 42 | # from settings, so this doesn't test errors in your custom formats 43 | # if they differ, it only tests that the formatTime method of our 44 | # formatter class is working. 45 | console_format = "%H:%M:%S,%F" 46 | file_format = "%Y-%m-%d %a %H:%M:%S,%F" 47 | 48 | @classmethod 49 | def setup_class(cls): 50 | """Set up these formatter tests with a hard-coded time.""" 51 | cls.record = logging.LogRecord(None, None, "", 0, "", (), None, None) 52 | cls.record.created = 1412096845.010138 53 | cls.record.msecs = 10.13803482055664 54 | 55 | def test_format_time_default(self): 56 | """Test calling formatTime without a datefmt argument. 57 | 58 | Doing so will cause it to fall back to the formatTime method on 59 | the logging library's Formatter class. 60 | 61 | """ 62 | line = self.formatter.formatTime(self.record) 63 | formatter = logging.Formatter() 64 | assert line == formatter.formatTime(self.record) 65 | 66 | def test_format_time_console(self): 67 | """Test calling formatTime with the console logging format.""" 68 | line = self.formatter.formatTime(self.record, self.console_format) 69 | dt = datetime.fromtimestamp(self.record.created) 70 | assert line == dt.strftime(self.console_format[:-3]) + ",010" 71 | 72 | def test_format_time_file(self): 73 | """Test calling formatTime with the file logging format.""" 74 | line = self.formatter.formatTime(self.record, self.file_format) 75 | dt = datetime.fromtimestamp(self.record.created) 76 | assert line == dt.strftime(self.file_format[:-3]) + ",010" 77 | -------------------------------------------------------------------------------- /cwmud/core/json.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """JSON data storage.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import json 8 | from os import listdir, makedirs, remove 9 | from os.path import abspath, exists, join, splitext 10 | 11 | from .. import settings 12 | from ..core.storage import DataStore 13 | from ..core.utils import joins 14 | 15 | 16 | class JSONStore(DataStore): 17 | 18 | """A store that keeps its data in the JSON format.""" 19 | 20 | _opens = False 21 | 22 | def __init__(self, subpath, indent=None, separators=None): 23 | """Create a new JSON store.""" 24 | super().__init__() 25 | self._path = join(settings.DATA_DIR, "json", subpath) 26 | self._indent = indent 27 | self._separators = separators 28 | # Make sure the path to the JSON store exists. 29 | if not exists(self._path): 30 | makedirs(self._path) 31 | 32 | def _get_key_path(self, key): 33 | """Validate and return an absolute path for a JSON file. 34 | 35 | :param str key: The key to store the data under 36 | :returns str: An absolute path to the JSON file for that key 37 | :raises OSError: If the path is not under this store's base path 38 | :raises TypeError: If the key is not a string 39 | 40 | """ 41 | if not isinstance(key, str): 42 | raise TypeError("JSON keys must be strings") 43 | path = abspath(join(self._path, key + ".json")) 44 | if not path.startswith(abspath(self._path)): 45 | raise OSError(joins("invalid path to JSON file:", path)) 46 | return path 47 | 48 | def _is_open(self): # pragma: no cover 49 | return True 50 | 51 | def _open(self): # pragma: no cover 52 | pass 53 | 54 | def _close(self): # pragma: no cover 55 | pass 56 | 57 | def _keys(self): 58 | """Return an iterator through the JSON files in this store.""" 59 | for name in listdir(abspath(self._path)): 60 | key, ext = splitext(name) 61 | if ext == ".json": 62 | yield key 63 | 64 | def _has(self, key): 65 | """Return whether a JSON file exists or not.""" 66 | path = self._get_key_path(key) 67 | return exists(path) 68 | 69 | def _get(self, key): 70 | """Fetch the data from a JSON file.""" 71 | path = self._get_key_path(key) 72 | with open(path, "r") as json_file: 73 | return json.load(json_file) 74 | 75 | def _put(self, key, data): 76 | """Store data in a JSON file.""" 77 | path = self._get_key_path(key) 78 | with open(path, "w") as json_file: 79 | json.dump(data, json_file, indent=self._indent, 80 | separators=self._separators) 81 | 82 | def _delete(self, key): 83 | """Delete a JSON file.""" 84 | path = self._get_key_path(key) 85 | remove(path) 86 | -------------------------------------------------------------------------------- /cwmud/core/npcs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Non-player characters.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import re 8 | 9 | from .attributes import Attribute 10 | from .characters import Character 11 | from .entities import ENTITIES 12 | from .logs import get_logger 13 | from .utils import joins 14 | 15 | 16 | log = get_logger("npcs") 17 | 18 | 19 | @ENTITIES.register 20 | class NPC(Character): 21 | 22 | """A non-player character.""" 23 | 24 | _uid_code = "N" 25 | 26 | type = "npc" 27 | 28 | def __repr__(self): 29 | return joins("NPC<", self.uid, ">", sep="") 30 | 31 | def get_name(self): 32 | """Get this character's name.""" 33 | return self.name 34 | 35 | def get_short_description(self): 36 | """Get this character's short description.""" 37 | return self.short 38 | 39 | def get_long_description(self): 40 | """Get this character's long description.""" 41 | raise self.long 42 | 43 | 44 | @NPC.register_attr("name") 45 | class NPCName(Attribute): 46 | 47 | """An NPC's name.""" 48 | 49 | _min_len = 2 50 | _max_len = 24 51 | _valid_chars = re.compile(r"^[a-zA-Z ]+$") 52 | default = "an NPC" 53 | 54 | # Other modules can add any reservations they need to this list. 55 | RESERVED = [] 56 | 57 | @classmethod 58 | def _validate(cls, entity, new_value): 59 | if (not isinstance(new_value, str) 60 | or not cls._valid_chars.match(new_value)): 61 | raise ValueError("NPC names can only contain letters and spaces.") 62 | name_len = len(new_value) 63 | if name_len < cls._min_len or name_len > cls._max_len: 64 | raise ValueError(joins("NPC names must be between", 65 | cls._min_len, "and", cls._max_len, 66 | "characters in length.")) 67 | if NPCName.check_reserved(new_value): 68 | raise ValueError("That name is reserved.") 69 | return new_value 70 | 71 | @classmethod 72 | def check_reserved(cls, name): 73 | """Check if an NPC name is reserved. 74 | 75 | :param str name: The NPC name to check 76 | :returns bool: True if the name is reserved, else False 77 | 78 | """ 79 | name = name.lower() 80 | for reserved in cls.RESERVED: 81 | if reserved.lower() == name: 82 | return True 83 | return False 84 | 85 | 86 | @NPC.register_attr("short") 87 | class NPCShortDesc(Attribute): 88 | 89 | """An NPC's short description.""" 90 | 91 | default = "Some sort of NPC is here." 92 | 93 | 94 | @NPC.register_attr("long") 95 | class NPCLongDesc(Attribute): 96 | 97 | """An NPC's long description.""" 98 | 99 | default = "There's nothing particularly interesting about them." 100 | -------------------------------------------------------------------------------- /tests/core/test_world.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for world entities.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import gc 8 | 9 | import pytest 10 | 11 | from cwmud.core.attributes import Unset 12 | from cwmud.core.players import Player 13 | from cwmud.core.world import Room 14 | 15 | 16 | class TestRooms: 17 | 18 | """A collection of tests for room entities.""" 19 | 20 | room = None 21 | 22 | def test_room_create(self): 23 | """Test that we can create a room.""" 24 | type(self).room = Room() 25 | 26 | def test_room_coords(self): 27 | """Test that room coordinates function properly.""" 28 | assert self.room.coords == (Unset, Unset, Unset) 29 | assert self.room.get_coord_str() == Unset 30 | self.room.x, self.room.y, self.room.z = (5, 5, 5) 31 | assert self.room.coords == (5, 5, 5) 32 | assert self.room.get_coord_str() == "5,5,5" 33 | with pytest.raises(ValueError): 34 | self.room.x = "123" 35 | with pytest.raises(ValueError): 36 | self.room.y = "123" 37 | with pytest.raises(ValueError): 38 | self.room.z = "123" 39 | 40 | def test_room_exits(self): 41 | """Test that room exits function properly.""" 42 | assert not self.room.get_exits() 43 | another_room = Room() 44 | another_room.x, another_room.y, another_room.z = (5, 5, 6) 45 | assert self.room.get_exits() == {"up": another_room} 46 | del Room._caches["uid"][another_room.uid] 47 | del another_room 48 | gc.collect() 49 | assert not self.room.get_exits() 50 | 51 | def test_room_chars(self): 52 | """Test that a room's character list functions properly.""" 53 | assert not self.room.chars 54 | char = Player() 55 | char.resume(quiet=True) 56 | char.room = self.room 57 | assert set(self.room.chars) == {char} 58 | char.room = Unset 59 | assert not self.room.chars 60 | 61 | def test_room_name(self): 62 | """Test that room names function properly.""" 63 | assert self.room.name == "An Unnamed Room" 64 | with pytest.raises(ValueError): 65 | self.room.name = 123 66 | with pytest.raises(ValueError): 67 | self.room.name = "x" * 61 68 | self.room.name = "test room" 69 | assert self.room.name == "Test Room" 70 | 71 | def test_room_desc(self): 72 | """Test that room descriptions function properly.""" 73 | assert self.room.description == "A nondescript room." 74 | with pytest.raises(ValueError): 75 | self.room.description = 123 76 | self.room.description = "A boring test room." 77 | assert self.room.description == "A boring test room." 78 | 79 | def test_movement_strings(self): 80 | """Test that we can generate movement strings.""" 81 | to_dir, from_dir = self.room.get_movement_strings((1, 0, 0)) 82 | assert to_dir == "east" 83 | assert from_dir == "the west" 84 | -------------------------------------------------------------------------------- /cwmud/core/help.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Help information management.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import fnmatch 8 | import re 9 | 10 | from .utils.bases import Manager 11 | 12 | 13 | class HelpSourceManager(Manager): 14 | 15 | """A manager for help source registration.""" 16 | 17 | def find(self, pattern): 18 | """Find one or more help entries matching a pattern. 19 | 20 | :param str pattern: A pattern to match entries against 21 | :returns list: A list of HelpEntry instances that match the pattern 22 | 23 | """ 24 | found = {} 25 | for source in self._items.values(): 26 | for entry in source.find(pattern): 27 | if entry.key not in found: 28 | found[entry.key] = entry 29 | return list(found.values()) 30 | 31 | 32 | class HelpSource: 33 | 34 | """A searchable source of help data.""" 35 | 36 | def __init__(self): 37 | """Create a new help source.""" 38 | self._entries = {} 39 | 40 | def __contains__(self, key): 41 | return key in self._entries 42 | 43 | def __getitem__(self, key): 44 | return self._entries[key] 45 | 46 | def __setitem__(self, key, value): 47 | self._entries[key] = value 48 | 49 | def __delitem__(self, key): 50 | del self._entries[key] 51 | 52 | def __iter__(self): 53 | return iter(self._entries) 54 | 55 | def keys(self): 56 | """Return an iterator through this source's entry keys.""" 57 | return self._entries.keys() 58 | 59 | def entries(self): 60 | """Return an iterator through this source's entries.""" 61 | return self._entries.values() 62 | 63 | def find(self, pattern): 64 | """Find one or more help entries matching a pattern. 65 | 66 | :param str pattern: A pattern to match entries against 67 | :returns list: A list of HelpEntry instances that match the pattern 68 | 69 | """ 70 | pattern = re.compile(fnmatch.translate(pattern)) 71 | matches = [] 72 | for entry in self.entries(): 73 | for topic in entry.topics: 74 | if pattern.match(topic): 75 | matches.append(entry) 76 | return matches 77 | 78 | 79 | class HelpEntry: 80 | 81 | """A single entry of help information.""" 82 | 83 | def __init__(self, key, title, text): 84 | """Create a new help entry.""" 85 | self._key = key 86 | self._related = set() 87 | self._text = text 88 | self._title = title 89 | self._topics = set() 90 | 91 | @property 92 | def related(self): 93 | """Return this entry's related topics.""" 94 | return frozenset(self._related) 95 | 96 | @property 97 | def text(self): 98 | """Return this entry's text.""" 99 | return self._text 100 | 101 | @property 102 | def topics(self): 103 | """Return this entry's topic keywords.""" 104 | return frozenset(self._topics) 105 | 106 | 107 | HELP_SOURCES = HelpSourceManager() 108 | -------------------------------------------------------------------------------- /cwmud/core/commands/movement/cardinal.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Primary movement command.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from .. import Command, COMMANDS 8 | from ...characters import CharacterShell 9 | 10 | 11 | @COMMANDS.register 12 | class DownCommand(Command): 13 | 14 | """A command to allow a character to move down.""" 15 | 16 | def _action(self): 17 | char = self.session.char 18 | if not char: 19 | self.session.send("You're not playing a character!") 20 | return 21 | if not char.room: 22 | self.session.send("You're not in a room!") 23 | return 24 | char.move_direction(z=-1) 25 | 26 | 27 | @COMMANDS.register 28 | class EastCommand(Command): 29 | 30 | """A command to allow a character to move east.""" 31 | 32 | def _action(self): 33 | char = self.session.char 34 | if not char: 35 | self.session.send("You're not playing a character!") 36 | return 37 | if not char.room: 38 | self.session.send("You're not in a room!") 39 | return 40 | char.move_direction(x=1) 41 | 42 | 43 | @COMMANDS.register 44 | class NorthCommand(Command): 45 | 46 | """A command to allow a character to move north.""" 47 | 48 | def _action(self): 49 | char = self.session.char 50 | if not char: 51 | self.session.send("You're not playing a character!") 52 | return 53 | if not char.room: 54 | self.session.send("You're not in a room!") 55 | return 56 | char.move_direction(y=1) 57 | 58 | 59 | @COMMANDS.register 60 | class SouthCommand(Command): 61 | 62 | """A command to allow a character to move south.""" 63 | 64 | def _action(self): 65 | char = self.session.char 66 | if not char: 67 | self.session.send("You're not playing a character!") 68 | return 69 | if not char.room: 70 | self.session.send("You're not in a room!") 71 | return 72 | char.move_direction(y=-1) 73 | 74 | 75 | @COMMANDS.register 76 | class WestCommand(Command): 77 | 78 | """A command to allow a character to move west.""" 79 | 80 | def _action(self): 81 | char = self.session.char 82 | if not char: 83 | self.session.send("You're not playing a character!") 84 | return 85 | if not char.room: 86 | self.session.send("You're not in a room!") 87 | return 88 | char.move_direction(x=-1) 89 | 90 | 91 | @COMMANDS.register 92 | class UpCommand(Command): 93 | 94 | """A command to allow a character to move up.""" 95 | 96 | def _action(self): 97 | char = self.session.char 98 | if not char: 99 | self.session.send("You're not playing a character!") 100 | return 101 | if not char.room: 102 | self.session.send("You're not in a room!") 103 | return 104 | char.move_direction(z=1) 105 | 106 | 107 | CharacterShell.add_verbs(DownCommand, "down", "d") 108 | CharacterShell.add_verbs(EastCommand, "east", "e") 109 | CharacterShell.add_verbs(NorthCommand, "north", "n") 110 | CharacterShell.add_verbs(SouthCommand, "south", "s") 111 | CharacterShell.add_verbs(WestCommand, "west", "w") 112 | CharacterShell.add_verbs(UpCommand, "up", "u") 113 | -------------------------------------------------------------------------------- /scripts/testweather.py: -------------------------------------------------------------------------------- 1 | 2 | from time import sleep, time 3 | from subprocess import call 4 | 5 | from cwmud.contrib.weather.patterns import WeatherPattern 6 | from cwmud.contrib.worldgen.maps import (_render_map_data, 7 | render_map_from_layers) 8 | from cwmud.contrib.worldgen.terrain import _parse_terrain_grid 9 | from cwmud.libs.miniboa import colorize 10 | 11 | 12 | def _weather_tiles(rain_value, wind_value): 13 | if -0.03 <= wind_value <= 0.03: 14 | if rain_value >= 0.7: 15 | return "^YH" 16 | else: 17 | return "^wW" 18 | else: 19 | if rain_value >= 0.6: 20 | return "^CL" 21 | if rain_value >= 0.4: 22 | return "^cR" 23 | if rain_value >= 0.3: 24 | return "^bR" 25 | return None 26 | 27 | 28 | if __name__ == "__main__": 29 | # Run a visual test of the weather system. 30 | rain_seed = time() % 10000 31 | wind_seed = rain_seed / 2 32 | rain_pattern = WeatherPattern(time_scale=2, formation_speed=0.25, 33 | storm_scale=50, wind_scale=20, 34 | seed=rain_seed) 35 | wind_pattern = WeatherPattern(time_scale=2, formation_speed=0.35, 36 | storm_scale=100, wind_scale=20, 37 | seed=wind_seed) 38 | _parse_terrain_grid() 39 | terrain_map = render_map_from_layers(*_render_map_data(109, 37, 40 | center=(-100, 100)), 41 | convert_color=False, join_tiles=False) 42 | while True: 43 | rain_pattern.update() 44 | wind_pattern.update() 45 | rain_data = rain_pattern.generate_layer(109, 37, octaves=2, 46 | fine_noise=0.1) 47 | wind_data = wind_pattern.generate_layer(109, 37, octaves=2, 48 | fine_noise=0.05) 49 | call("clear") 50 | sidebar = ("^MSeed: {:>#9.8}\n^YTime: {:>#6.5g}\n\n" 51 | "^BRain Storms:\n" 52 | "^GXY: {:=+#8g} {:=+#8g}\n^CMovement: {} @ {:#.4}^~\n\n" 53 | "^BWind Storms:\n" 54 | "^GXY: {:=+#8g} {:=+#8g}\n^CMovement: {} @ {:#.4}^~\n\n" 55 | "^wStorm Legend:\n" 56 | "^KLight ^bR^Kain\n^KHeavy ^cR^Kain\n" 57 | "^CL^Kightning\n^KStrong ^wW^Kind\n" 58 | "^YH^Kurricane Winds!^~" 59 | .format(rain_pattern.seed, 60 | round(rain_pattern.time_offset, 3), 61 | round(rain_pattern._offset_x[1], 3), 62 | round(rain_pattern._offset_y[1], 3), 63 | rain_pattern.get_wind_direction(), 64 | round(rain_pattern.get_wind_speed(), 3), 65 | round(wind_pattern._offset_x[1], 3), 66 | round(wind_pattern._offset_y[1], 3), 67 | wind_pattern.get_wind_direction(), 68 | round(wind_pattern.get_wind_speed(), 3)) 69 | .split("\n")) 70 | for y in range(len(rain_data)): 71 | tiles = [] 72 | for x in range(len(rain_data[y])): 73 | tile = _weather_tiles(rain_data[y][x], wind_data[y][x]) 74 | if tile is None: 75 | tile = terrain_map[y][x] 76 | tiles.append(tile) 77 | tiles.append("^~") 78 | row = "".join(tiles) 79 | if y < len(sidebar): 80 | row = row + " " + sidebar[y] 81 | print(colorize(row)) 82 | sleep(0.1) 83 | -------------------------------------------------------------------------------- /tests/core/test_json.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for JSON data storage.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from os.path import exists, join 8 | from shutil import rmtree 9 | 10 | import pytest 11 | 12 | from cwmud import settings 13 | from cwmud.core.json import JSONStore 14 | 15 | 16 | class TestJSONStores: 17 | 18 | """A collection of tests for JSON stores.""" 19 | 20 | store = None 21 | store_path = join(settings.DATA_DIR, "json", "test") 22 | json_path = join(store_path, "test.json") 23 | data = {"test": 123, "yeah": "okay"} 24 | 25 | @classmethod 26 | def setup_class(cls): 27 | """Clean up any previous test data directory.""" 28 | # In case tests were previously interrupted. 29 | if exists(cls.store_path): 30 | rmtree(cls.store_path) 31 | 32 | @classmethod 33 | def teardown_class(cls): 34 | """Clean up our test data directory.""" 35 | if exists(cls.store_path): 36 | rmtree(cls.store_path) 37 | 38 | def test_jsonstore_create(self): 39 | """Test that we can create a new JSON data store.""" 40 | # The path to this store's data shouldn't exist yet. 41 | assert not exists(self.store_path) 42 | type(self).store = JSONStore("test") 43 | # The directory for this store's data should have been created. 44 | assert exists(self.store_path) 45 | assert self.store 46 | 47 | def test_jsonstore_create_second(self): 48 | """Test that we can create a second JSON store with the same path.""" 49 | assert JSONStore("test") 50 | 51 | def test_jsonstore_get_key_path(self): 52 | """Test that we can get the full path of a JSON file by key.""" 53 | assert self.store._get_key_path("test") == self.json_path 54 | 55 | def test_jsonstore_get_key_path_key_not_string(self): 56 | """Test that trying to use a non-string as a JSON key fails.""" 57 | with pytest.raises(TypeError): 58 | self.store._get_key_path(5) 59 | with pytest.raises(TypeError): 60 | self.store._get_key_path(False) 61 | 62 | def test_jsonstore_get_key_path_invalid_path(self): 63 | """Test that trying to get a key path outside a store fails.""" 64 | with pytest.raises(OSError): 65 | self.store._get_key_path("../../test") 66 | 67 | def test_jsonstore_no_keys(self): 68 | """Test that we can check if a JSON store has no keys.""" 69 | assert not tuple(self.store.keys()) 70 | 71 | def test_jsonstore_put(self): 72 | """Test that we can put data into a JSON store.""" 73 | assert not exists(self.json_path) 74 | self.store._put("test", self.data) 75 | assert exists(self.json_path) 76 | 77 | def test_jsonstore_has(self): 78 | """Test that we can tell if a JSON store has a key.""" 79 | assert self.store._has("test") 80 | assert not self.store._has("nonexistent_key") 81 | 82 | def test_jsonstore_keys(self): 83 | """Test that we can iterate through a JSON store's keys.""" 84 | # We need to stick a non-JSON file in there to ensure it isn't 85 | # picked up as a key. 86 | with open(join(self.store_path, "nope.pkl"), "w") as out: 87 | out.write("\n") 88 | # And let's put another valid key in for good measure. 89 | self.store._put("yeah", {}) 90 | assert tuple(sorted(self.store._keys())) == ("test", "yeah") 91 | 92 | def test_jsonstore_get(self): 93 | """Test that we can get data from a JSON store.""" 94 | assert self.store._get("test") == self.data 95 | 96 | def test_jsonstore_delete(self): 97 | """Test that we can delete data from a JSON store.""" 98 | assert exists(self.json_path) 99 | assert self.store._has("test") 100 | self.store._delete("test") 101 | assert not exists(self.json_path) 102 | assert not self.store._has("test") 103 | -------------------------------------------------------------------------------- /tests/core/test_pickle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for pickle serialization and storage.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from os.path import exists, join 8 | from shutil import rmtree 9 | 10 | import pytest 11 | 12 | from cwmud import settings 13 | from cwmud.core.pickle import PickleStore 14 | 15 | 16 | class TestPickleStores: 17 | 18 | """A collection of tests for pickle stores.""" 19 | 20 | store = None 21 | store_path = join(settings.DATA_DIR, "pickle", "test") 22 | pickle_path = join(store_path, "test.pkl") 23 | data = {"test": 123, "yeah": "okay"} 24 | 25 | @classmethod 26 | def setup_class(cls): 27 | """Clean up any previous test data directory.""" 28 | # In case tests were previously interrupted. 29 | if exists(cls.store_path): 30 | rmtree(cls.store_path) 31 | 32 | @classmethod 33 | def teardown_class(cls): 34 | """Clean up our test data directory.""" 35 | if exists(cls.store_path): 36 | rmtree(cls.store_path) 37 | 38 | def test_picklestore_create(self): 39 | """Test that we can create a new pickle data store.""" 40 | # The path to this store's data shouldn't exist yet. 41 | assert not exists(self.store_path) 42 | type(self).store = PickleStore("test") 43 | # The directory for this store's data should have been created. 44 | assert exists(self.store_path) 45 | assert self.store 46 | 47 | def test_picklestore_create_second(self): 48 | """Test that we can create a second pickle store with the same path.""" 49 | assert PickleStore("test") 50 | 51 | def test_picklestore_get_key_path(self): 52 | """Test that we can get the full path of a pickle file by key.""" 53 | assert self.store._get_key_path("test") == self.pickle_path 54 | 55 | def test_picklestore_get_key_path_key_not_string(self): 56 | """Test that trying to use a non-string as a pickle key fails.""" 57 | with pytest.raises(TypeError): 58 | self.store._get_key_path(5) 59 | with pytest.raises(TypeError): 60 | self.store._get_key_path(False) 61 | 62 | def test_picklestore_get_key_path_invalid_path(self): 63 | """Test that trying to get a key path outside a store fails.""" 64 | with pytest.raises(OSError): 65 | self.store._get_key_path("../../test") 66 | 67 | def test_picklestore_no_keys(self): 68 | """Test that we can check if a pickle store has no keys.""" 69 | assert not tuple(self.store.keys()) 70 | 71 | def test_picklestore_put(self): 72 | """Test that we can put data into a pickle store.""" 73 | assert not exists(self.pickle_path) 74 | self.store._put("test", self.data) 75 | assert exists(self.pickle_path) 76 | 77 | def test_picklestore_has(self): 78 | """Test that we can tell if this store has a key.""" 79 | assert self.store._has("test") 80 | assert not self.store._has("nonexistent_key") 81 | 82 | def test_picklestore_keys(self): 83 | """Test that we can iterate through a pickle store's keys.""" 84 | # We need to stick a non-pickle file in there to ensure it isn't 85 | # picked up as a key. 86 | with open(join(self.store_path, "nope.txt"), "w") as out: 87 | out.write("\n") 88 | # And let's put another valid key in for good measure. 89 | self.store._put("yeah", {}) 90 | assert tuple(sorted(self.store._keys())) == ("test", "yeah") 91 | 92 | def test_picklestore_get(self): 93 | """Test that we can get data from a pickle store.""" 94 | assert self.store._get("test") == self.data 95 | 96 | def test_picklestore_delete(self): 97 | """Test that we can delete data from a pickle store.""" 98 | assert exists(self.pickle_path) 99 | assert self.store._has("test") 100 | self.store._delete("test") 101 | assert not exists(self.pickle_path) 102 | assert not self.store._has("test") 103 | -------------------------------------------------------------------------------- /tests/core/test_commands.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Test for command management and processing.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import pytest 8 | 9 | from cwmud.core.commands import AlreadyExists, Command, CommandManager 10 | 11 | 12 | class TestCommands: 13 | 14 | """A collection of tests for command management.""" 15 | 16 | commands = None 17 | command_class = None 18 | command = None 19 | 20 | class _FakeSession: 21 | pass 22 | 23 | session = _FakeSession() 24 | 25 | def test_command_manager_create(self): 26 | """Test that we can create a command manager. 27 | 28 | This is currently redundant, importing the commands package already 29 | creates one, but we can keep it for symmetry and in case that 30 | isn't always so. 31 | 32 | """ 33 | type(self).commands = CommandManager() 34 | assert self.commands 35 | 36 | def test_command_manager_get_name(self): 37 | """Test that we can figure out the name for an argument.""" 38 | assert self.commands._get_name(Command) == "Command" 39 | assert self.commands._get_name("TestCommand") == "TestCommand" 40 | 41 | def test_command_manager_register(self): 42 | 43 | """Test that we can register new commands through a command manager.""" 44 | 45 | @self.commands.register 46 | class TestCommand(Command): 47 | 48 | """A test command.""" 49 | 50 | def __init__(self, session, args): 51 | super().__init__(session, args) 52 | self.called = False 53 | 54 | def _action(self): 55 | self.called = True 56 | 57 | type(self).command_class = TestCommand 58 | assert "TestCommand" in self.commands 59 | 60 | def test_command_manager_register_by_argument(self): 61 | """Test that we can register a new command by argument.""" 62 | self.commands.register(command=Command) 63 | assert "Command" in self.commands 64 | 65 | def test_command_manager_register_not_command(self): 66 | """Test that trying to register a non-command fails.""" 67 | with pytest.raises(TypeError): 68 | self.commands.register(command=object()) 69 | 70 | def test_command_manager_register_already_exists(self): 71 | """Test that trying to register an existing command name fails.""" 72 | with pytest.raises(AlreadyExists): 73 | self.commands.register(command=self.command_class) 74 | 75 | def test_command_manager_contains(self): 76 | """Test that we can see if a command manager contains a command.""" 77 | assert "TestCommand" in self.commands 78 | assert Command in self.commands 79 | assert "some_nonexistent_command" not in self.commands 80 | assert CommandManager not in self.commands 81 | 82 | def test_command_manager_get_command(self): 83 | """Test that we can get a command from a command manager.""" 84 | assert self.commands["TestCommand"] is self.command_class 85 | with pytest.raises(KeyError): 86 | self.commands["some_nonexistent_command"].process() 87 | 88 | def test_command_instance(self): 89 | """Test that we can create a command instance.""" 90 | type(self).command = self.command_class(None, ()) 91 | assert self.command 92 | 93 | def test_command_execute_no_session(self): 94 | """Test that a command instance without a session won't execute.""" 95 | self.command.execute() 96 | assert not self.command.called 97 | 98 | def test_command_session_property(self): 99 | """Test that we can get and set the session property of a command.""" 100 | assert self.command.session is None 101 | self.command.session = self.session 102 | assert self.command.session is self.session 103 | 104 | def test_command_execute(self): 105 | """Test that we can execute a command.""" 106 | self.command.execute() 107 | assert self.command.called 108 | -------------------------------------------------------------------------------- /cwmud/core/items.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Item management and containers.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from collections import Counter 8 | 9 | from .attributes import Attribute, ListAttribute, Unset 10 | from .entities import ENTITIES, Entity 11 | from .logs import get_logger 12 | from .utils import joins 13 | 14 | 15 | log = get_logger("items") 16 | 17 | 18 | class ItemListAttribute(ListAttribute): 19 | 20 | """An attribute for a list of items.""" 21 | 22 | class Proxy(ListAttribute.Proxy): 23 | 24 | def __repr__(self): 25 | return repr(self._items) 26 | 27 | def __setitem__(self, index, value): 28 | if not isinstance(value, Item): 29 | raise TypeError(joins(value, "is not an Item")) 30 | value.container = self._entity 31 | super().__setitem__(index, value) 32 | 33 | def __delitem__(self, index): 34 | self._items[index].container = Unset 35 | super().__delitem__(index) 36 | 37 | def insert(self, index, value): 38 | if not isinstance(value, Item): 39 | raise TypeError(joins(value, "is not an Item")) 40 | value.container = self._entity 41 | super().insert(index, value) 42 | 43 | def get_counts(self): 44 | return Counter([item.name for item in self._items]).items() 45 | 46 | def get_weight(self): 47 | return sum([item.get_weight() for item in self._items]) 48 | 49 | @classmethod 50 | def serialize(cls, entity, value): 51 | return [item.uid for item in value] 52 | 53 | @classmethod 54 | def deserialize(cls, entity, value): 55 | return cls.Proxy(entity, [Item.get(uid) for uid in value]) 56 | 57 | 58 | @ENTITIES.register 59 | class Item(Entity): 60 | 61 | """An item.""" 62 | 63 | _uid_code = "I" 64 | 65 | type = "item" 66 | 67 | def __repr__(self): 68 | return joins("Item<", self.uid, ">", sep="") 69 | 70 | def get_weight(self): 71 | """Get the weight of this item.""" 72 | return self.weight 73 | 74 | 75 | @Item.register_attr("nouns") 76 | class ItemNouns(Attribute): 77 | 78 | """An item's nouns.""" 79 | 80 | _default = "item" 81 | 82 | 83 | @Item.register_attr("name") 84 | class ItemName(Attribute): 85 | 86 | """An item's name.""" 87 | 88 | _default = "an item" 89 | 90 | 91 | @Item.register_attr("short") 92 | class ItemShortDesc(Attribute): 93 | 94 | """An item's short description.""" 95 | 96 | _default = "Some sort of item is lying here." 97 | 98 | 99 | @Item.register_attr("long") 100 | class ItemLongDesc(Attribute): 101 | 102 | """An item's long description.""" 103 | 104 | _default = "There's nothing particularly interesting about it." 105 | 106 | 107 | @Item.register_attr("weight") 108 | class ItemWeight(Attribute): 109 | 110 | """An item's weight.""" 111 | 112 | _default = 0 113 | 114 | 115 | @Item.register_attr("container") 116 | class ItemContainer(Attribute): 117 | 118 | """The container an item is in. 119 | 120 | Changing this does not set a reverse relationship, so setting this 121 | will generally be managed by the container. 122 | 123 | """ 124 | 125 | @classmethod 126 | def serialize(cls, entity, value): 127 | return value.uid 128 | 129 | @classmethod 130 | def deserialize(cls, entity, value): 131 | container = Item.get(value) 132 | if not container: 133 | log.warning("Could not load container '%s' for %s.", 134 | value, entity) 135 | return container 136 | 137 | 138 | @ENTITIES.register 139 | class Container(Item): 140 | 141 | """A container item.""" 142 | 143 | type = "container" 144 | 145 | def get_weight(self): 146 | """Get the weight of this container and its contents.""" 147 | return self.weight + self.contents.get_weight() 148 | 149 | 150 | @Container.register_attr("contents") 151 | class ContainerContents(ItemListAttribute): 152 | 153 | """The contents of a container.""" 154 | -------------------------------------------------------------------------------- /cwmud/core/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Command management and processing.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from os.path import join 8 | 9 | from ... import BASE_PACKAGE, ROOT_DIR 10 | from ..events import EVENTS 11 | from ..logs import get_logger 12 | from ..utils import recursive_import 13 | from ..utils.exceptions import AlreadyExists 14 | from ..utils.mixins import HasFlags, HasFlagsMeta, HasWeaks, HasWeaksMeta 15 | 16 | 17 | log = get_logger("commands") 18 | 19 | 20 | class CommandManager: 21 | 22 | """A manager for command registration and control. 23 | 24 | This is a convenience manager and is not required for the server to 25 | function. All of its functionality can be achieved by subclassing, 26 | instantiating, and referencing commands directly. 27 | 28 | """ 29 | 30 | def __init__(self): 31 | """Create a new command manager.""" 32 | self._commands = {} 33 | 34 | def __contains__(self, command): 35 | return self._get_name(command) in self._commands 36 | 37 | def __getitem__(self, command): 38 | return self._commands[self._get_name(command)] 39 | 40 | @staticmethod 41 | def _get_name(command): 42 | if isinstance(command, type): 43 | return command.__name__ 44 | else: 45 | return command 46 | 47 | def register(self, command): 48 | """Register a command. 49 | 50 | This method can be used to decorate a Command class. 51 | 52 | :param Command command: The command to be registered 53 | :returns Command: The registered command 54 | :raises AlreadyExists: If a command with that class name already exists 55 | :raises TypeError: If the supplied or decorated class is not a 56 | subclass of Command 57 | 58 | """ 59 | if (not isinstance(command, type) 60 | or not issubclass(command, Command)): 61 | raise TypeError("must be subclass of Command to register") 62 | name = command.__name__ 63 | if name in self._commands: 64 | raise AlreadyExists(name, self._commands[name], command) 65 | self._commands[name] = command 66 | return command 67 | 68 | 69 | class _CommandMeta(HasFlagsMeta, HasWeaksMeta): 70 | # To avoid multiple metaclass errors. 71 | pass 72 | 73 | 74 | class Command(HasFlags, HasWeaks, metaclass=_CommandMeta): 75 | 76 | """A command for performing actions through a shell.""" 77 | 78 | # Whether this command receives its arguments un-parsed. 79 | no_parse = False 80 | 81 | def __init__(self, session, args): 82 | """Create a new command instance.""" 83 | super(Command, self).__init__() 84 | self.session = session 85 | self.args = args 86 | 87 | @property 88 | def session(self): 89 | """Return the current session for this command.""" 90 | return self._get_weak("session") 91 | 92 | @session.setter 93 | def session(self, new_session): 94 | """Set the current session for this command. 95 | 96 | :param Session new_session: The session tied to this command 97 | :returns None: 98 | 99 | """ 100 | self._set_weak("session", new_session) 101 | 102 | def execute(self): 103 | """Validate conditions and then perform this command's action.""" 104 | if not self.session: 105 | return 106 | self._action() 107 | 108 | def _action(self): # pragma: no cover 109 | """Do something; override this to add your functionality.""" 110 | 111 | 112 | # We create a global CommandManager here for convenience, and while the server 113 | # will generally only need one to work with, they are NOT singletons and you 114 | # can make more CommandManager instances if you like. 115 | COMMANDS = CommandManager() 116 | 117 | 118 | @EVENTS.hook("server_boot") 119 | def _hook_server_boot(): 120 | # Import commands modules. 121 | commands_package = ".".join((BASE_PACKAGE, "core", "commands")) 122 | base_path = join(ROOT_DIR, BASE_PACKAGE, "core", "commands") 123 | recursive_import(base_path, commands_package) 124 | -------------------------------------------------------------------------------- /cwmud/core/logs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Logging configuration and support.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from datetime import datetime 8 | import logging 9 | from logging.config import dictConfig 10 | from os import makedirs 11 | from os.path import dirname, exists 12 | import re 13 | 14 | from .. import settings 15 | 16 | 17 | # Make sure the folder where our log will go exists. 18 | if not exists(dirname(settings.LOG_PATH)): 19 | # We can't test this without reload the module and doing so breaks 20 | # a bunch of other tests that rely on logging. 21 | makedirs(dirname(settings.LOG_PATH)) # pragma: no cover 22 | 23 | 24 | class _Formatter(logging.Formatter): 25 | 26 | """Custom formatter for our logging handlers.""" 27 | 28 | def formatTime(self, record, datefmt=None): 29 | """Convert a LogRecord's creation time to a string. 30 | 31 | If `datefmt` is provided, it will be used to convert the time through 32 | datetime.strftime. If not, it falls back to the formatTime method of 33 | logging.Formatter, which converts the time through time.strftime. 34 | 35 | This custom processing allows for the full range of formatting options 36 | provided by datetime.strftime as opposed to time.strftime. 37 | 38 | There is additional parsing done to allow for the %F argument to be 39 | converted to 3-digit zero-padded milliseconds, as an alternative to 40 | the %f argument's usual 6-digit microseconds (because frankly that's 41 | just too many digits). 42 | 43 | :param LogRecord record: The record to be formatted 44 | :param str datefmt: A formatting string to be passed to strftime 45 | :returns str: A formatted time string 46 | 47 | """ 48 | if datefmt: 49 | msecs = str(int(record.msecs)).zfill(3) 50 | datefmt = re.sub(r"(? self._offset_x[0]: 77 | if self._offset_y[1] > self._offset_y[0]: 78 | return "SW" 79 | else: 80 | return "NW" 81 | if self._offset_x[1] < self._offset_x[0]: 82 | if self._offset_y[1] > self._offset_y[0]: 83 | return "SE" 84 | else: 85 | return "NE" 86 | return "??" 87 | 88 | def get_wind_speed(self): 89 | """Calculate the current wind speed.""" 90 | x_speed = abs(self._offset_x[1] - self._offset_x[0]) 91 | y_speed = abs(self._offset_y[1] - self._offset_y[0]) 92 | return (x_speed + y_speed) / 2 93 | 94 | def generate_layer(self, width, height, center=(0, 0), 95 | octaves=1, persistence=0.5, lacunarity=2.0, 96 | fine_noise=None): 97 | """Generate one layer of weather data using simplex noise. 98 | 99 | :param int width: The width of the weather map 100 | :param int height: The height of the weather map 101 | :param tuple(int, int) center: The center of the weather map as (x, y) 102 | :param int octaves: The number of passes to make calculating noise 103 | :param float persistence: The amplitude multiplier per octave 104 | :param float lacunarity: The frequency multiplier per octave 105 | :param fine_noise: A multiplier for an extra fine noise layer 106 | :returns: A generated map layer 107 | 108 | """ 109 | formation_shift = self._time_offset * self.formation_speed 110 | max_x = width // 2 111 | max_y = height // 2 112 | rows = [] 113 | offset_x = 0 if self._offset_x is None else self._offset_x[1] 114 | offset_y = 0 if self._offset_y is None else self._offset_y[1] 115 | for y in range(center[1] + max_y, 116 | center[1] - max_y - (height % 2), 117 | -1): 118 | row = [] 119 | for x in range(center[0] - max_x, 120 | center[0] + max_x + (width % 2)): 121 | storm_noise = generate_noise(x, y, formation_shift, self.seed, 122 | scale=self.storm_scale, 123 | offset_x=offset_x, 124 | offset_y=offset_y, 125 | octaves=octaves, 126 | persistence=persistence, 127 | lacunarity=lacunarity) 128 | if fine_noise is not None: 129 | fine_value = generate_noise(x, y, 0, -self.seed, scale=1) 130 | fine_value *= fine_noise 131 | row.append(max(-1, min(storm_noise + fine_value, 1))) 132 | else: 133 | row.append(storm_noise) 134 | rows.append(row) 135 | return rows 136 | -------------------------------------------------------------------------------- /cwmud/core/players.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Player characters.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import re 8 | 9 | from .attributes import Attribute 10 | from .channels import CHANNELS 11 | from .characters import Character 12 | from .entities import ENTITIES 13 | from .events import EVENTS 14 | from .logs import get_logger 15 | from .requests import Request, REQUESTS 16 | from .utils import joins 17 | 18 | 19 | log = get_logger("players") 20 | 21 | 22 | @ENTITIES.register 23 | class Player(Character): 24 | 25 | """A player character.""" 26 | 27 | _uid_code = "P" 28 | 29 | type = "player" 30 | 31 | def __repr__(self): 32 | if hasattr(self, "name") and self.name: 33 | return joins("Player<", self.name, ">", sep="") 34 | else: 35 | return "Player<(unnamed)>" 36 | 37 | def get_name(self): 38 | """Get this character's name.""" 39 | return self.name 40 | 41 | def get_short_description(self): 42 | """Get this character's short description.""" 43 | return "^G{} ^g{}^g is here.^~".format(self.name, self.title) 44 | 45 | def get_long_description(self): 46 | """Get this character's long description.""" 47 | raise NotImplementedError 48 | 49 | def resume(self, quiet=False): 50 | """Bring this character into play. 51 | 52 | :param bool quiet: Whether to suppress output from resuming or not 53 | :returns None: 54 | 55 | """ 56 | with EVENTS.fire("char_login", self): 57 | if self.room: 58 | self.room.chars.add(self) 59 | self.active = True 60 | if not quiet: 61 | log.info("%s has entered the game.", self) 62 | CHANNELS["announce"].send(self.name, "has logged in.") 63 | self.show_room() 64 | 65 | def suspend(self, quiet=False): 66 | """Remove this character from play. 67 | 68 | :param bool quiet: Whether to suppress output from resuming or not 69 | :returns None: 70 | 71 | """ 72 | with EVENTS.fire("char_logout", self): 73 | if not quiet: 74 | log.info("%s has left the game.", self) 75 | CHANNELS["announce"].send(self.name, "has logged out.") 76 | self.active = False 77 | if self.room and self in self.room.chars: 78 | self.room.chars.remove(self) 79 | 80 | 81 | @Player.register_attr("account") 82 | class PlayerAccount(Attribute): 83 | 84 | """The account tied to a player.""" 85 | 86 | @classmethod 87 | def validate(cls, entity, new_value): 88 | from .accounts import Account 89 | if not isinstance(new_value, Account): 90 | raise ValueError("Player account must be an Account instance.") 91 | return new_value 92 | 93 | @classmethod 94 | def serialize(cls, entity, value): 95 | # Save player accounts by UID. 96 | return value.uid 97 | 98 | @classmethod 99 | def deserialize(cls, entity, value): 100 | from .accounts import Account 101 | return Account.get(value) 102 | 103 | 104 | @Player.register_attr("name") 105 | class PlayerName(Attribute): 106 | 107 | """A player name.""" 108 | 109 | _min_len = 2 110 | _max_len = 16 111 | _valid_chars = re.compile(r"^[a-zA-Z]+$") 112 | 113 | # Other modules can add any reservations they need to this list. 114 | # Reserved names should be in Titlecase. 115 | RESERVED = [] 116 | 117 | @classmethod 118 | def validate(cls, entity, new_value): 119 | if (not isinstance(new_value, str) 120 | or not cls._valid_chars.match(new_value)): 121 | raise ValueError("Character names can only contain letters.") 122 | name_len = len(new_value) 123 | if name_len < cls._min_len or name_len > cls._max_len: 124 | raise ValueError(joins("Character names must be between", 125 | cls._min_len, "and", cls._max_len, 126 | "letters in length.")) 127 | new_value = new_value.title() 128 | if PlayerName.check_reserved(new_value): 129 | raise ValueError("That name is reserved.") 130 | if Player.find(name=new_value): 131 | raise ValueError("That name is already in use.") 132 | return new_value 133 | 134 | @classmethod 135 | def check_reserved(cls, name): 136 | """Check if a player name is reserved. 137 | 138 | :param str name: The player name to check 139 | :returns bool: True if the name is reserved, else False 140 | 141 | """ 142 | return name in cls.RESERVED 143 | 144 | 145 | @REQUESTS.register 146 | class RequestNewPlayerName(Request): 147 | 148 | """A request for a new player name.""" 149 | 150 | initial_prompt = joins("Enter a new character name (character names must" 151 | " be between", PlayerName._min_len, "and", 152 | PlayerName._max_len, "letters in length): ") 153 | repeat_prompt = "New character name: " 154 | confirm = Request.CONFIRM_YES 155 | confirm_prompt_yn = "'{data}', is that correct? (Y/N) " 156 | 157 | def _validate(self, data): 158 | try: 159 | new_name = PlayerName.validate(None, data) 160 | except ValueError as exc: 161 | raise Request.ValidationFailed(*exc.args) 162 | return new_name 163 | 164 | 165 | @Player.register_attr("title") 166 | class PlayerTitle(Attribute): 167 | 168 | """A player title.""" 169 | 170 | _default = "the newbie" 171 | 172 | 173 | def create_player(session, callback, player=None): 174 | """Perform a series of requests to create a new player. 175 | 176 | :param sessions.Session session: The session creating a player 177 | :param callable callback: A callback for when the player is created 178 | :param Player player: The player in the process of being created 179 | :returns None: 180 | 181 | """ 182 | if not player: 183 | player = Player() 184 | player._savable = False 185 | if not player.name: 186 | def _set_name(_session, new_name): 187 | player.name = new_name 188 | create_player(_session, callback, player) 189 | session.request(RequestNewPlayerName, _set_name) 190 | else: 191 | player.account = session.account 192 | player._savable = True 193 | callback(session, player) 194 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | cwmud 2 | ===== 3 | An extendable, modular MUD server. 4 | 5 | |version| |license| |pyversions| |build| |coverage| 6 | 7 | Clockwork is a pure-Python MUD server designed with modularity and ease of development in mind. 8 | 9 | 10 | Current State 11 | ------------- 12 | 13 | This isn't a viable MUD server yet. There's account creation, basic character creation, rooms, talking, and walking around, but that's about it. There are also no permission controls on admin commands yet, so anyone can do whatever they want (which is good because there is no concept of an admin yet). 14 | 15 | The ``reload`` command was broken with the recent changes to client/protocol serving (the server reloads, but any connected clients will get dumped into a limbo state of disconnected I/O), but it should be fixed in the next minor version. 16 | 17 | So anyway, very under-construction at the moment. 18 | 19 | 20 | License 21 | ------- 22 | 23 | Clockwork uses the MIT license. See the `license file`_ for more details. 24 | 25 | 26 | Installation 27 | ------------ 28 | 29 | First, it is *highly* recommended that you set up a `virtualenv`_ to run Clockwork in:: 30 | 31 | $ cd mymud 32 | $ virtualenv -p python3 --no-site-packages .venv 33 | $ source .venv/bin/activate 34 | 35 | Then, Clockwork can be installed through pip:: 36 | 37 | $ pip install cwmud 38 | 39 | *Note: If not using virtualenv (you should!), you will need to run this command with elevated privileges (sudo).* 40 | 41 | 42 | Dependencies 43 | ------------ 44 | 45 | Clockwork runs on `Python`_ 3.4 and is as yet untested on any later versions. There are currently no plans to support earlier versions. 46 | 47 | Clockwork requires a running `Redis`_ server and the `redis-py`_ bindings package for messages, and `bcrypt`_ for password hashing (and bcrypt in turn requires libffi). It also makes use of `miniboa-py3`_, a Python 3 port of `miniboa`_, which is a tiny, asynchronous Telnet server. Our modified copy of miniboa is included in ``cwmud/libs``. 48 | 49 | To install the libffi library on Debian/Ubuntu, run:: 50 | 51 | $ sudo apt-get install libffi-dev 52 | 53 | See the `Redis Quick Start`_ guide for details on installing and configuring Redis. 54 | 55 | 56 | Configuration 57 | ------------- 58 | 59 | All the post-installation configuration settings are stored in ``cwmud/settings.py``. 60 | 61 | Some key settings you'll probably want to change: 62 | 63 | ``DEFAULT_HOST``: The IP to bind the listener to, default is ``"localhost"`` (127.0.0.1), change to ``"0.0.0.0"`` to allow external connections. 64 | 65 | ``DEFAULT_PORT``: The port to listen for new Telnet connections on, default is ``4000``. 66 | 67 | ``LOG_PATH``: The path for the server log, defaults to ``"./logs/mud.log"`` (rotates daily at midnight, which are also settings that can be changed). 68 | 69 | ``DATA_DIR``: The path to a folder where local data should be loaded from and saved to (serialized objects, flat text files, etc.), defaults to ``"./data"``. 70 | 71 | These (and other) options can also be set on a per-run basis using command-line options (see below). 72 | 73 | 74 | Usage 75 | ----- 76 | 77 | To start the Clockwork server, simply run:: 78 | 79 | $ python -m cwmud 80 | 81 | 82 | For a full list of uses and options, see the help output by running:: 83 | 84 | $ python -m cwmud --help 85 | 86 | 87 | After booting, the server will be ready to accept Telnet connections on whatever address and port you specified in ``cwmud/settings.py`` (default is localhost and port 4000). 88 | 89 | 90 | Testing 91 | ------- 92 | 93 | Clockwork includes a suite of unit tests in `pytest`_ format. To run the test suite you will first need to install pytest and the plugins we use (coverage, flake8, timeout). To install all of the test suite dependencies, run:: 94 | 95 | $ pip install -r tests/requirements.txt 96 | 97 | *Note: If not using virtualenv (you should!), you will need to run this command with elevated privileges (sudo).* 98 | 99 | 100 | After pytest is installed, you can run the test suite via our Makefile:: 101 | 102 | $ make tests 103 | 104 | If you don't have ``make`` available (a make.bat file will be in the works for Windows users), you can call pytest directly like so:: 105 | 106 | $ py.test --flake8 cwmud tests 107 | 108 | 109 | Development 110 | ----------- 111 | 112 | * Git repository: https://github.com/whutch/cwmud 113 | * Project planning: https://github.com/whutch/cwmud/projects 114 | * Issue tracker: https://github.com/whutch/cwmud/issues 115 | 116 | Please read the `style guide`_ for coding conventions and style guidelines before submitting any pull requests or committing changes. 117 | 118 | 119 | Contact & Support 120 | ----------------- 121 | 122 | * Homepage: *(not yet)* 123 | * Documentation: *(not hosted yet, but you can build it in* ``docs`` *)* 124 | * Wiki: https://github.com/whutch/cwmud/wiki 125 | 126 | You can email me questions and comments at will@whutch.com. You can also find me as Kazan on the `Mud Coders Slack group`_ (you can find the sign-up page on the `Mud Coders Guild blog`_). 127 | 128 | .. |build| image:: https://circleci.com/gh/whutch/cwmud/tree/master.svg?style=shield 129 | :target: https://circleci.com/gh/whutch/cwmud/tree/master 130 | :alt: Latest build via CircleCI 131 | .. |coverage| image:: https://codecov.io/github/whutch/cwmud/coverage.svg?branch=master 132 | :target: https://codecov.io/github/whutch/cwmud?branch=master 133 | :alt: Latest code coverage via Codecov 134 | .. |license| image:: https://img.shields.io/pypi/l/cwmud.svg 135 | :target: https://github.com/whutch/cwmud/blob/master/LICENSE.txt 136 | :alt: MIT license 137 | .. |pyversions| image:: https://img.shields.io/pypi/pyversions/cwmud.svg 138 | :target: http://pypi.python.org/pypi/cwmud/ 139 | :alt: Supported Python versions 140 | .. |version| image:: https://img.shields.io/pypi/v/cwmud.svg 141 | :target: https://pypi.python.org/pypi/cwmud 142 | :alt: Latest version on PyPI 143 | 144 | .. _bcrypt: https://github.com/pyca/bcrypt 145 | .. _license file: https://github.com/whutch/cwmud/blob/master/LICENSE.txt 146 | .. _miniboa: https://code.google.com/p/miniboa 147 | .. _miniboa-py3: https://github.com/pR0Ps/miniboa-py3 148 | .. _Mud Coders Guild blog: http://mudcoders.com 149 | .. _Mud Coders Slack group: https://mudcoders.slack.com 150 | .. _pytest: https://pytest.org/latest 151 | .. _Python: https://www.python.org 152 | .. _Redis: http://redis.io 153 | .. _Redis Quick Start: http://redis.io/topics/quickstart 154 | .. _redis-py: https://pypi.python.org/pypi/redis 155 | .. _style guide: https://github.com/whutch/cwmud/blob/master/STYLE.md 156 | .. _virtualenv: https://virtualenv.pypa.io 157 | -------------------------------------------------------------------------------- /cwmud/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Utility and support modules.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from importlib import import_module 8 | from os.path import join 9 | from pkgutil import iter_modules 10 | 11 | 12 | import bcrypt 13 | 14 | 15 | def joins(*parts, sep=" "): 16 | """Join a sequence as a string with given separator. 17 | 18 | This is a shortcut function that saves you the effort of converting 19 | each element in a str.join(sequence) call to a str first. 20 | 21 | :param sequence parts: A sequence of items to join 22 | :param str sep: The separator to join them with 23 | :returns str: The newly joined string 24 | 25 | """ 26 | if not parts or not any(parts): 27 | return "" 28 | return sep.join(map(str, parts)) 29 | 30 | 31 | def type_name(obj): 32 | """Fetch the type name of an object. 33 | 34 | This is a cosmetic shortcut. I find the normal method very ugly. 35 | 36 | :param any obj: The object you want the type name of 37 | :returns str: The type name 38 | 39 | """ 40 | return type(obj).__name__ 41 | 42 | 43 | def class_name(obj): 44 | """Fetch the class name of an object (class or instance). 45 | 46 | Another cosmetic shortcut. The builtin way of getting an instance's class 47 | name is pretty disgusting (and long), accessing two hidden attributes in 48 | a row just feels wrong. 49 | 50 | :param any obj: The object you want the class name of 51 | :returns str: The class name 52 | 53 | """ 54 | if isinstance(obj, type): 55 | # It's a class. 56 | return obj.__name__ 57 | else: 58 | # It's an instance of a class. 59 | return obj.__class__.__name__ 60 | 61 | 62 | def can_be_index(obj): 63 | """Determine if an object can be used as the index of a sequence. 64 | 65 | :param any obj: The object to test 66 | :returns bool: Whether it can be an index or not 67 | 68 | """ 69 | try: 70 | [][obj] 71 | except TypeError: 72 | return False 73 | except IndexError: 74 | return True 75 | 76 | 77 | def is_iterable(obj): 78 | """Determine if an object is iterable. 79 | 80 | :param any obj: The object to test 81 | :returns bool: Whether it is iterable or not 82 | 83 | """ 84 | try: 85 | for _ in obj: 86 | break 87 | except TypeError: 88 | return False 89 | else: 90 | return True 91 | 92 | 93 | def is_hashable(obj): 94 | """Determine if an object is hashable. 95 | 96 | :param any obj: The object to test 97 | :returns bool: Whether it is hashable or not 98 | 99 | """ 100 | try: 101 | hash(obj) 102 | except TypeError: 103 | return False 104 | else: 105 | return True 106 | 107 | 108 | def find_by_attr(collection, attr, value): 109 | """Find objects in a collection that have an attribute equal to a value. 110 | 111 | :param iterable collection: The collection to search through 112 | :param str attr: The attribute to search by 113 | :param any value: The value to search for 114 | :returns list: A list of any matching objects 115 | 116 | """ 117 | unset = object() 118 | matches = [] 119 | for obj in collection: 120 | match = getattr(obj, attr, unset) 121 | if match is not unset and (match is value or match == value): 122 | matches.append(obj) 123 | return matches 124 | 125 | 126 | def int_to_base_n(integer, charset): 127 | """Convert an integer into a base-N string using a given character set. 128 | 129 | :param int integer: The integer to convert 130 | :param str charset: The character set to use for the conversion 131 | :returns str: The converted string 132 | :raises ValueError: If `charset` contains duplicate characters 133 | 134 | """ 135 | if len(charset) > len(set(charset)): 136 | raise ValueError("character set contains duplicate characters") 137 | base = len(charset) 138 | integer = int(integer) 139 | places = [] 140 | while integer >= base: 141 | places.append(charset[integer % base]) 142 | integer //= base 143 | places.append(charset[integer]) 144 | return "".join(reversed(places)) 145 | 146 | 147 | def base_n_to_int(string, charset): 148 | """Convert a base-N string into an integer using a given character set. 149 | 150 | :param str string: The string to convert 151 | :param str charset: The character set to use for the conversion 152 | :returns str: The converted string 153 | :raises ValueError: If `charset` contains duplicate characters 154 | 155 | """ 156 | if len(charset) > len(set(charset)): 157 | raise ValueError("character set contains duplicate characters") 158 | base = len(charset) 159 | integer = 0 160 | for index, char in enumerate(reversed(string)): 161 | integer += charset.index(char) * (base ** index) 162 | return integer 163 | 164 | 165 | def generate_hash(string): 166 | """Generate a cryptographic hash from a string. 167 | 168 | :param str string: A string to generate the hash from 169 | :returns str: The generated hash 170 | 171 | """ 172 | byte_string = string.encode() 173 | hashed_string = bcrypt.hashpw(byte_string, bcrypt.gensalt()) 174 | return hashed_string.decode() 175 | 176 | 177 | def check_hash(string, hashed_string): 178 | """Check that an input string matches a given hash. 179 | 180 | :param str string: The input string 181 | :param str hashed_string: The hash to compare to 182 | :returns bool: Whether the input string matches the given hash 183 | 184 | """ 185 | byte_string = string.encode() 186 | byte_hash = hashed_string.encode() 187 | return bcrypt.hashpw(byte_string, byte_hash) == byte_hash 188 | 189 | 190 | def recursive_import(base_path, base_package): 191 | """Recursively import a package and all submodules or subpackages. 192 | 193 | :param str base_path: Path to the top level package 194 | :param str base_package: Import path to top level package 195 | :returns None: 196 | 197 | """ 198 | def _import_package(path, prefix): 199 | import_module(prefix, base_package) 200 | for finder, name, is_pkg in iter_modules([path]): 201 | if is_pkg: 202 | _import_package(join(path, name), ".".join((prefix, name))) 203 | else: 204 | import_module(".".join((prefix, name)), base_package) 205 | _import_package(base_path, base_package) 206 | -------------------------------------------------------------------------------- /tests/core/test_requests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tests for input request management.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | import pytest 8 | 9 | from cwmud.core.requests import AlreadyExists, Request, RequestManager 10 | from cwmud.core.utils import joins 11 | 12 | 13 | class TestRequests: 14 | 15 | """A collection of tests for request management.""" 16 | 17 | requests = None 18 | request_class = None 19 | request = None 20 | 21 | class _FakeSession: 22 | 23 | def __init__(self): 24 | self._output = [] 25 | 26 | def send(self, data, *more, sep=" ", end="\n"): 27 | return self._output.append(joins(data, *more, sep=sep) + end) 28 | 29 | session = _FakeSession() 30 | 31 | def test_request_manager_create(self): 32 | """Test that we can create a request manager. 33 | 34 | This is currently redundant, importing the requests package already 35 | creates one, but we can keep it for symmetry and in case that 36 | isn't always so. 37 | 38 | """ 39 | type(self).requests = RequestManager() 40 | assert self.requests 41 | 42 | def test_request_manager_register(self): 43 | 44 | """Test that we can register a new request through a manager.""" 45 | 46 | @self.requests.register 47 | class TestRequest(Request): 48 | """A test request.""" 49 | 50 | def _validate(self, data): 51 | if not isinstance(data, str): 52 | raise self.ValidationFailed("should be a str") 53 | return data.upper() 54 | 55 | type(self).request_class = TestRequest 56 | assert "TestRequest" in self.requests._requests 57 | 58 | def test_request_manager_register_already_exists(self): 59 | """Test that trying to re-register a request fails.""" 60 | with pytest.raises(AlreadyExists): 61 | self.requests.register(self.request_class) 62 | 63 | def test_request_manager_register_not_request(self): 64 | """Test that trying to register a non-request fails.""" 65 | with pytest.raises(TypeError): 66 | self.requests.register(object()) 67 | 68 | def test_request_manager_contains(self): 69 | """Test that we can see if a request manager contains a request.""" 70 | assert "TestRequest" in self.requests 71 | assert "SomeNonExistentRequest" not in self.requests 72 | 73 | def test_request_manager_get_request(self): 74 | """Test that we can get a request from a request manager.""" 75 | assert self.requests["TestRequest"] is self.request_class 76 | with pytest.raises(KeyError): 77 | self.requests["SomeNonExistentRequest"].resolve("yeah") 78 | 79 | def test_request_create(self): 80 | """Test that we can create a new request instance.""" 81 | type(self).request = self.request_class(self.session, None) 82 | assert self.request 83 | 84 | def test_request_validation(self): 85 | """Test that we can validate input for a request.""" 86 | assert Request._validate(self.request, "toot") == "toot" 87 | assert self.request._validate("toot") == "TOOT" 88 | with pytest.raises(Request.ValidationFailed): 89 | self.request._validate(123) 90 | 91 | def test_request_get_prompt(self): 92 | """Test that we can generate request prompts.""" 93 | # First test without previous input to confirm. 94 | assert self.request.get_prompt() == self.request.repeat_prompt 95 | assert self.request.get_prompt() == self.request.repeat_prompt 96 | self.request.initial_prompt = "Yeah? " 97 | self.request.flags.drop("prompted") 98 | assert self.request.get_prompt() == self.request.initial_prompt 99 | assert self.request.get_prompt() == self.request.repeat_prompt 100 | # Then test confirmation prompts. 101 | self.request._confirm = "yeah" 102 | self.request.confirm = Request.CONFIRM_YES 103 | assert (self.request.get_prompt() 104 | == self.request.confirm_prompt_yn.format(data="yeah")) 105 | self.request.confirm = Request.CONFIRM_REPEAT 106 | assert (self.request.get_prompt() 107 | == self.request.confirm_prompt_repeat) 108 | self.request.confirm = "bad confirm type" 109 | with pytest.raises(ValueError): 110 | self.request.get_prompt() 111 | self.request.flags.drop("prompted") 112 | 113 | def test_request_resolve_no_confirm(self): 114 | """Test that we can resolve a request without confirmation.""" 115 | self.request._confirm = None 116 | self.request.confirm = None 117 | assert self.request.resolve("test") is True 118 | assert not self.request._confirm 119 | self.request.options["validator"] = lambda value: value 120 | self.request.callback = lambda session, data: session.send(data) 121 | assert self.request.resolve("another test") is True 122 | assert self.request.session._output.pop() == "ANOTHER TEST\n" 123 | 124 | def test_request_resolve_yn_confirm(self): 125 | """Test that we can resolve a request with yes/no confirmation.""" 126 | self.request.confirm = Request.CONFIRM_YES 127 | assert self.request.resolve("test") is False 128 | assert self.request._confirm == "TEST" 129 | assert self.request.resolve("no") is False 130 | assert not self.request._confirm 131 | assert self.request.resolve("test") is False 132 | assert self.request._confirm == "TEST" 133 | assert self.request.resolve("yes") is True 134 | 135 | def test_request_resolve_repeat_confirm(self): 136 | """Test that we can resolve a request with repeat confirmation.""" 137 | self.request._confirm = None 138 | self.request.confirm = Request.CONFIRM_REPEAT 139 | assert self.request.resolve(123) is False 140 | assert not self.request._confirm 141 | assert self.request.resolve("test") is False 142 | assert self.request.resolve("testtt") is False 143 | assert self.request.resolve("test") is False 144 | assert self.request.resolve(123) is False 145 | assert self.request.resolve("test") is False 146 | assert self.request.resolve("test") is True 147 | 148 | def test_request_resolve_invalid_confirm(self): 149 | """Test that we can detect a bad request confirmation type.""" 150 | self.request.confirm = "bad confirm type" 151 | with pytest.raises(ValueError): 152 | self.request.resolve("test") 153 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = .build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Clockwork.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Clockwork.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Clockwork" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Clockwork" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=.build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Clockwork.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Clockwork.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /cwmud/core/clients.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Universal client management and communication.""" 3 | # Part of Clockwork MUD Server (https://github.com/whutch/cwmud) 4 | # :copyright: (c) 2008 - 2017 Will Hutcheson 5 | # :license: MIT (https://github.com/whutch/cwmud/blob/master/LICENSE.txt) 6 | 7 | from collections import deque 8 | from time import time as now 9 | 10 | from .events import EVENTS 11 | from .logs import get_logger 12 | from .messages import BROKER, get_pubsub 13 | from .text import strip_caret_codes 14 | 15 | 16 | log = get_logger("clients") 17 | 18 | 19 | # Might replace this with something more sophisticated. 20 | CLIENT_MANAGERS = {} 21 | 22 | 23 | class Client: 24 | 25 | """A client handler.""" 26 | 27 | PENDING, OPEN, CLOSING, CLOSED = range(4) 28 | 29 | def __init__(self, protocol, uid, host, port): 30 | """Create a new client handler.""" 31 | self._commands = deque() 32 | self._host = host 33 | self._last_command_time = now() 34 | self._messages = get_pubsub() 35 | self._port = port 36 | self._protocol = protocol 37 | self._uid = uid 38 | self._messages.subscribe("{}:input:{}".format(protocol, uid)) 39 | self.allow_formatting = False 40 | self.state = self.OPEN 41 | 42 | def __repr__(self): 43 | return "{}{}{}".format(self._host, 44 | ":" if self._port is not None else "", 45 | self._port if self._port is not None else "") 46 | 47 | @property 48 | def active(self): 49 | """Return whether this client is active or not.""" 50 | return self.state == self.OPEN 51 | 52 | @property 53 | def uid(self): 54 | """Return the UID for this client.""" 55 | return self._uid 56 | 57 | @property 58 | def host(self): 59 | """Return the host address of this client.""" 60 | return self._host 61 | 62 | @property 63 | def port(self): 64 | """Return the port this client is connected to.""" 65 | return self._port 66 | 67 | @property 68 | def protocol(self): 69 | """Return the protocol used by this client.""" 70 | return self._protocol 71 | 72 | @property 73 | def command_pending(self): 74 | """Return whether there is a pending command or not.""" 75 | return len(self._commands) > 0 76 | 77 | def close(self): 78 | """Forcibly close this client's connection.""" 79 | BROKER.publish("{}:close".format(self._protocol), self._uid) 80 | self.state = self.CLOSING 81 | 82 | def get_command(self): 83 | """Get the next command in the queue, if there is one.""" 84 | if len(self._commands) > 0: 85 | return self._commands.popleft() 86 | else: 87 | return None 88 | 89 | def get_idle_time(self): 90 | """Calculate how long this client has been idle, in seconds.""" 91 | return now() - self._last_command_time 92 | 93 | def poll(self): 94 | """Process any queued IO for this client.""" 95 | if self.state == self.OPEN: 96 | message = self._messages.get_message() 97 | while message: 98 | data = message["data"] 99 | self._commands.append(data) 100 | message = self._messages.get_message() 101 | if message: 102 | self._last_command_time = now() 103 | 104 | def send(self, data, strip_formatting=False): 105 | """Send data to this client. 106 | 107 | :param str data: The data to send 108 | :param bool strip_formatting: Whether to strip formatting codes out 109 | of the data before sending 110 | :returns None: 111 | 112 | """ 113 | if strip_formatting or not self.allow_formatting: 114 | data = strip_caret_codes(data) 115 | BROKER.publish("{}:output:{}".format(self._protocol, self._uid), data) 116 | 117 | 118 | class ClientManager: 119 | 120 | """A manager for client handlers.""" 121 | 122 | def __init__(self, protocol, client_class=Client): 123 | """Create a new client manager.""" 124 | self._client_class = client_class 125 | self._clients = {} 126 | self._messages = get_pubsub() 127 | self._protocol = protocol 128 | self._messages.subscribe("{}:connect".format(protocol)) 129 | self._messages.subscribe("{}:disconnect".format(protocol)) 130 | 131 | def _add_client(self, uid, host, port, quiet=False): 132 | if uid in self._clients: 133 | log.warning("UID collision! {}:{}.".format(self._protocol, uid)) 134 | client = self._client_class(self._protocol, uid, host, port) 135 | self._clients[uid] = client 136 | if not quiet: 137 | with EVENTS.fire("client_connected", client, no_pre=True): 138 | log.info("Incoming connection from %s.", client) 139 | return client 140 | 141 | def _remove_client(self, uid): 142 | client = self._clients.get(uid) 143 | if not client: 144 | return 145 | with EVENTS.fire("client_disconnected", client, no_pre=True): 146 | log.info("Lost connection from %s.", client) 147 | client.state = client.CLOSED 148 | del self._clients[uid] 149 | 150 | def find_by_uid(self, uid): 151 | """Find a client handler by its UID. 152 | 153 | :param str uid: The UID of the client to find 154 | :returns Client: The found client or None 155 | 156 | """ 157 | return self._clients.get(uid) 158 | 159 | def check_connections(self): 160 | """Check for new/disconnected clients.""" 161 | message = self._messages.get_message() 162 | while message: 163 | channel = message["channel"] 164 | data = message["data"] 165 | if channel == "{}:connect".format(self._protocol): 166 | uid, host, port = data.split(":") 167 | self._add_client(uid, host, port) 168 | elif channel == "{}:disconnect".format(self._protocol): 169 | self._remove_client(data) 170 | message = self._messages.get_message() 171 | 172 | def poll(self): 173 | """Process any queued IO for all clients.""" 174 | check = list(self._clients.values()) 175 | for client in check: 176 | if client.state == client.OPEN: 177 | client.poll() 178 | elif client.state == client.CLOSING: 179 | client.state = client.CLOSED 180 | del self._clients[client.uid] 181 | 182 | 183 | @EVENTS.hook("server_save_state", "clients", pre=True) 184 | def _hook_server_save_state(state): 185 | managers = {} 186 | for protocol, manager in CLIENT_MANAGERS.items(): 187 | clients = {} 188 | for uid, client in manager._clients.items(): 189 | if client.state != client.OPEN: 190 | continue 191 | clients[uid] = ( 192 | client.host, client.port, client.allow_formatting, 193 | client._last_command_time, client._commands) 194 | managers[protocol] = clients 195 | state["clients"] = managers 196 | 197 | 198 | @EVENTS.hook("server_load_state", "clients") 199 | def _hook_server_load_state(state): 200 | managers = state["clients"] 201 | for protocol, clients in managers.items(): 202 | manager = CLIENT_MANAGERS[protocol] 203 | for uid, (host, port, allow_formatting, 204 | last_command_time, commands) in clients.items(): 205 | client = manager._add_client(uid, host, port, quiet=True) 206 | client.allow_formatting = allow_formatting 207 | client._last_command_time = last_command_time 208 | client._commands = commands 209 | --------------------------------------------------------------------------------