diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 71d3cb1..3620507 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,7 @@ ENV/ doc/_build *.swp + +# PyCharm IDE files +.git/ +.idea/ \ No newline at end of file diff --git a/adventurelib.py b/adventurelib.py old mode 100644 new mode 100755 index bc33605..0722fd1 --- a/adventurelib.py +++ b/adventurelib.py @@ -1,25 +1,27 @@ +"""Simple library for writing Text Adventures in Python""" import re import sys import inspect -import readline import textwrap import random from copy import deepcopy -from functools import partial -from itertools import zip_longest try: from shutil import get_terminal_size except ImportError: - from backports.shutil_get_terminal_size import get_terminal_size -else: - def get_terminal_size(fallback=(80, 24)): - return fallback + try: + from backports.shutil_get_terminal_size import get_terminal_size + except ImportError: + def get_terminal_size(fallback=(80, 24)): + """Fallback definition for terminal size getting""" + return fallback __all__ = ( 'when', 'start', 'Room', + 'Pattern', + '_handle_command', 'Item', 'Bag', 'say', @@ -30,6 +32,10 @@ class InvalidCommand(Exception): """A command is not defined correctly.""" +class InvalidDirection(Exception): + """User attempts to travel in an invalid direction.""" + + class Placeholder: """Match a word in a command string.""" def __init__(self, name): @@ -47,12 +53,12 @@ class Room: @staticmethod def add_direction(forward, reverse): """Add a direction.""" - for dir in (forward, reverse): - if not dir.islower(): + for direc in (forward, reverse): + if not direc.islower(): raise InvalidCommand( 'Invalid direction %r: directions must be all lowercase.' ) - if dir in Room._directions: + if direc in Room._directions: raise KeyError('%r is already a direction!' % dir) Room._directions[forward] = reverse Room._directions[reverse] = forward @@ -100,6 +106,7 @@ def __setattr__(self, name, value): else: object.__setattr__(self, name, value) + Room.add_direction('north', 'south') Room.add_direction('east', 'west') @@ -153,7 +160,7 @@ def __contains__(self, v): if isinstance(v, str): return bool(self.find(v)) else: - return set.__contains__(v) + return set.__contains__(self, v) def take(self, name): """Remove an Item from the bag if it is present. @@ -193,12 +200,14 @@ def take_random(self): return obj -def _register(command, func, kwargs={}): +def _register(command, func, kwargs=None): """Register func as a handler for the given command.""" + if kwargs is None: + kwargs = {} pattern = Pattern(command) sig = inspect.signature(func) func_argnames = set(sig.parameters) - when_argnames = set(pattern.argnames) | set(kwargs.keys()) + when_argnames = set(pattern.argnames) | set(kwargs.keys()) | {'game'} if func_argnames != when_argnames: raise InvalidCommand( 'The function %s%s has the wrong signature for @when(%r)' % ( @@ -212,6 +221,7 @@ def _register(command, func, kwargs={}): class Pattern: + """Command-matching pattern""" def __init__(self, pattern): self.orig_pattern = pattern words = pattern.split() @@ -221,7 +231,7 @@ def __init__(self, pattern): for w in words: if not w.isalpha(): raise InvalidCommand( - 'Invalid command %r' % command + + 'Invalid command %r' % w + 'Commands may consist of letters only.' ) if w.isupper(): @@ -233,7 +243,7 @@ def __init__(self, pattern): match.append(w) else: raise InvalidCommand( - 'Invalid command %r' % command + + 'Invalid command %r' % w + '\n\nWords in commands must either be in lowercase or ' + 'capitals, not a mix.' ) @@ -254,6 +264,7 @@ def __repr__(self): @staticmethod def word_combinations(have, placeholders): + """??? (not sure what this does)""" if have < placeholders: return if have == placeholders: @@ -272,9 +283,10 @@ def word_combinations(have, placeholders): combos = Pattern.word_combinations(remain, other_groups) for buckets in combos: yield (take,) + tuple(buckets) - take -= 1 # backtrack + take -= 1 # backtrack def match(self, input_words): + """Attempt to match a command against the pattern""" if len(input_words) < len(self.argnames): return None @@ -326,12 +338,13 @@ def no_command_matches(command): def when(command, **kwargs): """Decorator for command functions.""" def dec(func): + """decorator""" _register(command, func, kwargs) return func return dec -def help(): +def cmd_help(): """Print a list of the commands you can give.""" print('Here is a list of the commands you can give:') cmds = sorted(c.orig_pattern for c, _, _ in commands) @@ -339,7 +352,7 @@ def help(): print(c) -def _handle_command(cmd): +def _handle_command(cmd, game=None): """Handle a command typed by the user.""" ws = cmd.lower().split() for pattern, func, kwargs in commands: @@ -347,26 +360,89 @@ def _handle_command(cmd): matches = pattern.match(ws) if matches is not None: args.update(matches) - func(**args) + func(**args, game=game) break else: no_command_matches(cmd) print() -def start(help=True): +class Interface: + """Superclass for all interfaces (ways of interacting with the game)""" + def __init__(self): + pass + + def get_command(self, prompt): + """Get a command""" + return '' + + def say(self, msg): + """Send output to the user""" + pass + + +class TerminalInterface(Interface): + """Interface for basic terminal I/O (the default)""" + def get_command(self, prompt): + """Get a command""" + return input(prompt).strip() + + def say(self, msg): + """Print a message. + + Unlike print(), this deals with de-denting and wrapping of text to fit + within the width of the terminal. + + Paragraphs separated by blank lines in the input will be wrapped + separately. + + """ + msg = str(msg) + msg = re.sub(r'^[ \t]*(.*?)[ \t]*$', r'\1', msg, flags=re.M) + width = get_terminal_size()[0] + paragraphs = re.split(r'\n(?:[ \t]*\n)', msg) + formatted = (textwrap.fill(p.strip(), width=width) for p in paragraphs) + print('\n\n'.join(formatted)) + + +class Game: + """Game World Environment""" + def __init__(self, interface: Interface): + self.worldvars = {} + self.interface = interface + self.say = self.interface.say + + def __getattr__(self, item): + try: + return self.worldvars[item] + except KeyError: + raise AttributeError() + + def __setattr__(self, attr, val): + if attr in {'worldvars', 'interface', 'say'}: + print(' in dict') + self.__dict__[attr] = val + else: + self.worldvars[attr] = val + + +def start(setup=None, interface: Interface=TerminalInterface(), help=True): """Run the game.""" if help: - # Ugly, but we want to keep the arguments consistent - help = globals()['help'] qmark = Pattern('help') qmark.prefix = ['?'] qmark.orig_pattern = '?' - commands.insert(0, (Pattern('help'), help, {})) - commands.insert(0, (qmark, help, {})) + commands.insert(0, (Pattern('help'), cmd_help, {})) + commands.insert(0, (qmark, cmd_help, {})) + + game = Game(interface) + + if setup is not None: + setup(game) + while True: try: - cmd = input(prompt()).strip() + cmd = interface.get_command(prompt()) except EOFError: print() break @@ -374,7 +450,7 @@ def start(help=True): if not cmd: continue - _handle_command(cmd) + _handle_command(cmd, game) def say(msg): diff --git a/demo_game.py b/demo_game.py old mode 100644 new mode 100755 index e242fb6..4d78d9f --- a/demo_game.py +++ b/demo_game.py @@ -1,8 +1,9 @@ +"""Demonstration game""" from adventurelib import * Room.items = Bag() -current_room = starting_room = Room(""" +starting_room = Room(""" You are in a dark room. """) @@ -11,58 +12,61 @@ """) mallet = Item('rusty mallet', 'mallet') -valley.items = Bag({mallet,}) - -inventory = Bag() +valley.items = Bag({mallet, }) @when('north', direction='north') @when('south', direction='south') @when('east', direction='east') @when('west', direction='west') -def go(direction): - global current_room - room = current_room.exit(direction) +def go(direction, game): + room = game.current_room.exit(direction) if room: - current_room = room - say('You go %s.' % direction) - look() + game.current_room = room + game.say('You go %s.' % direction) + look(game) @when('take ITEM') -def take(item): - obj = current_room.items.take(item) +def take(item, game): + obj = game.current_room.items.take(item) if obj: - say('You pick up the %s.' % obj) - inventory.add(obj) + game.say('You pick up the %s.' % obj) + game.inventory.add(obj) else: - say('There is no %s here.' % item) + game.say('There is no %s here.' % item) @when('drop THING') -def drop(thing): - obj = inventory.take(thing) +def drop(thing, game): + obj = game.inventory.take(thing) if not obj: - say('You do not have a %s.' % thing) + game.say('You do not have a %s.' % thing) else: - say('You drop the %s.' % obj) - current_room.items.add(obj) + game.say('You drop the %s.' % obj) + game.current_room.items.add(obj) @when('look') -def look(): - say(current_room) - if current_room.items: - for i in current_room.items: - say('A %s is here.' % i) +def look(game): + game.say(game.current_room) + if game.current_room.items: + for i in game.current_room.items: + game.say('A %s is here.' % i) @when('inventory') -def show_inventory(): - say('You have:') - for thing in inventory: - say(thing) +def show_inventory(game): + game.say('You have:') + for thing in game.inventory: + game.say(thing) + + +def setup(game): + """Set up the game world""" + game.current_room = starting_room + game.inventory = Bag() + look(game) -look() -start() +start(setup) diff --git a/test_adventurelib.py b/test_adventurelib.py old mode 100644 new mode 100755 index 5d16e58..bfe1273 --- a/test_adventurelib.py +++ b/test_adventurelib.py @@ -1,4 +1,3 @@ -import os from unittest.mock import patch from contextlib import redirect_stdout from io import StringIO @@ -122,9 +121,9 @@ def test_register_args(): args = None @when('north', dir='north') - def func(dir): + def func(direc): nonlocal args - args = [dir] + args = [direc] _handle_command('north') assert args == ['north'] @@ -186,33 +185,22 @@ def test_say_wrap2(): def test_say_paragraph(): out = say_at_width(40, """ - This is a long sentence that the say command will wrap. + This is a long sentence that the say command will wrap, + and this clause is indented to match. And this is a second paragraph that is separately wrapped. """) assert out == ( "This is a long sentence that the say\n" - "command will wrap.\n" + "command will wrap, and this clause is\n" + "indented to match.\n" "\n" "And this is a second paragraph that is\n" "separately wrapped.\n" ) -def test_say_paragraph(): - out = say_at_width(40, """ - This is a long sentence that the say command will wrap, - and this clause is indented to match. - """) - - assert out == ( - "This is a long sentence that the say\n" - "command will wrap, and this clause is\n" - "indented to match.\n" - ) - - @patch('random.randrange', return_value=0) def test_bag_get_random(randrange): """We can select an item from a bag at random."""