Rejigger object tree and use objects for more things

- Verbs are now special callable objects
- Document almost everything
main
Cassowary Rusnov 3 months ago
parent 9390458ffb
commit 518f0101e2
  1. 112
      pyoo/base.py
  2. 62
      pyoo/interpret.py
  3. 59
      pyoo/placeloader.py
  4. 164
      pyoo/things.py
  5. 7
      testrooms.py
  6. 3
      testverb.py
  7. 8
      tox.ini

@ -1,5 +1,10 @@
"""
Base data and types for supporting Pyoo operations.
"""
from collections import namedtuple
from typing import Callable, List, Tuple, Type, Sequence, Union, Iterable, Protocol
PREPOSITIONS = (
"with/using",
@ -20,45 +25,98 @@ PREPOSITIONS = (
"as",
"off",
)
"""`tuple[str]`: A list of preposition strings (equivilancies are / separated)"""
NORMALIZED_PREPS = tuple([x.split("/") for x in PREPOSITIONS])
"""A list of prepositions with equiviliants split."""
class PyooError(Exception):
pass
"""Generic Pyoo error."""
class PyooVerbNotFound(PyooError):
pass
"""Pyoo Error which indicates a verb was not found."""
class PyooObjectNotFound(PyooError):
pass
"""Pyoo Error which indicates an object was not found."""
NORMALIZED_PREPS = tuple([x.split("/") for x in PREPOSITIONS])
VerbCallFrame = namedtuple("VerbCallFrame", "environment,player,verbname,dobj,dobjstr,prepstr,iobj,iobjstr,argstr")
"""Namedtuple which contains the call frame (all arguments) for a verb call."""
class Verb:
"""This is a wrapper for a callable to give it the properties required for verbs."""
def __init__(self,
function: Callable,
names: List[str],
callspec: Tuple[str, List[str], str]):
self.function = function
self.name = names[0]
self.names = names
self.callspec = callspec
def __call__(self, *args, **kwargs) -> None:
return self.function(*args, **kwargs)
def __get__(self, obj, objtype: Type):
class VerbFunction:
def __init__(self, func: Callable, obj, typ: Type, verb: "Verb"):
self._func = func
self._obj = obj
self._typ = typ
self.is_verb = True
self.name = verb.name
self.names = verb.names
self.callspec = verb.callspec
# this simple decorator adds verb metadata to a method or function
# verbname is a comma-separated list of verb names with possible woldcard
# dobjspec is 'this' or 'that' or 'none' or 'any' (this = the object which defines the verb, that = an object in the
# soup, any = any string, none = blank)
# iobjspec is 'this' or 'that' or 'none' or 'any'
# prepspec is one of prepositions strings
# verb prototypes are: (verb_callframe, argstr)
def __call__(self, *args, **kwargs):
return self._func.__get__(self._obj, self._typ)(*args, **kwargs)
def make_verb(verbname, dobjspec, prepspec, iobjspec):
def verb_decorate(verbfunc):
return VerbFunction(self.function, obj, objtype, self)
def __repr__(self) -> str:
return f"<Verb {self.name} {self.callspec}>"
def make_verb(verbname: str, dobjspec: str, prepspec: str, iobjspec: str) -> Callable[..., Verb]:
"""This simple decorator adds verb metadata to a method or function.
Arguments:
verbname (str): List of verb names, comma separated, with wildcards.
dobjspec (str): 'this' or 'that' or 'none' or 'any'
(this = the object which defines the verb, that = an object in the soup, any = any string,
none = blank)
iobjspec (str): 'this' or 'that' or 'none' or 'any' (as above)
prepspec (str): one of prepositions strings (as in the PREPOSITIONS list)
"""
def verb_decorate(verbfunc: Callable[[VerbCallFrame, ], None]) -> Verb:
names = [x.strip() for x in verbname.split(",")]
verbfunc.name = names[0]
verbfunc.names = names
ps = prepspec
if isinstance(ps, str):
if ps.find("/") > 0:
ps = ps.split("/")
else:
for p in NORMALIZED_PREPS:
if ps in p:
ps = p
break
verbfunc.callspec = (dobjspec, ps, iobjspec)
verbfunc.is_verb = True
return verbfunc
ps: List[str] = []
if prepspec.find("/") > 0:
ps = prepspec.split("/")
else:
for prep in NORMALIZED_PREPS:
if prepspec in prep:
ps = prep
break
ps = [prepspec]
return Verb(verbfunc, names, (dobjspec, ps, iobjspec))
return verb_decorate
#
# type things
#
class InterpreterProtocol(Protocol):
"""Defines minimal protocol for interpreter objects."""
def update(self) -> None:
...
SingleMultiString = Union[Sequence[str], str, Iterable[str]]

@ -1,53 +1,78 @@
"""This module defines the basic interpreter system."""
import fnmatch
from typing import Optional, List, Set, Tuple, cast
from .things import Thing, Container, Place, Player
from .base import VerbCallFrame, PyooVerbNotFound, PyooObjectNotFound
class Interpreter(object):
def __init__(self, contents = None):
class Interpreter:
"""Base interpreter class.
Manages a collection of objects, and invoking verbs on them.
"""
def __init__(self, contents: Optional[List[Thing]] = None):
"""Initialize an interpreter.
Arguments:
contents (optional list of things): The initial contents to populate the interpreter with.
"""
if contents is None:
contents = []
self.contents = set(contents)
self.contents: Set[Thing] = set(contents)
for cont in self.contents:
cont.interpreter = self
self.content_cache = []
self.content_cache: List[Tuple[str, Thing]] = []
self.update()
def add_player(self, player_object):
def add_player(self, player_object: Player) -> None:
"""Add a player to the interpreter."""
self.contents.add(player_object)
player_object.interpreter = self
self.update()
def remove_player(self, player_object):
def remove_player(self, player_object: Player) -> None:
"""Remove a player from the interpreter."""
self.contents.remove(player_object)
self.update()
def update(self):
def update(self) -> None:
"""Do any updates required when objects are added or removed."""
self.update_caches()
def update_caches(self):
def update_caches(self) -> None:
"""Update the caches for objects contained in the interpreter, as well as updating all of the caches
on contained containers.
"""
self.content_cache = []
for obj in self.contents:
for name in obj.names:
self.content_cache.append((name, obj))
try:
obj.update_caches()
cast(Container, obj).update_caches()
except AttributeError:
pass
def handle_move(self, newroom, player):
def handle_move(self, newroom: Place, player: Player) -> None:
"""Move a player to a new contained room."""
if player.location:
player.location.handle_exit(player)
newroom.handle_enter(player)
def handle_get(self, thing):
pass
def lookup_global_object(self, objstr):
def lookup_global_object(self, objstr: str) -> List[Tuple[str, Thing]]:
"""Find an object within the soup by objstr."""
return [x for x in self.content_cache if fnmatch.fnmatch(objstr, x[0])]
def lookup_object(self, player, objstr):
m = None
def lookup_object(self, player: Player, objstr: str) -> Optional[Thing]:
"""Find an object relative to a player
This first checks the player's inventory, and then the container which contains the
player.
"""
m: Optional[List[Tuple[str, Thing]]] = None
try:
m = player.get_name_matches(objstr)
except PyooObjectNotFound:
@ -59,8 +84,8 @@ class Interpreter(object):
else:
return None
def interpret(self, command, player):
def interpret(self, command: str, player: Player) -> None:
"""Interpret a player's command by splitting it into components and finding matching verbs."""
# FIXME make this better to support mulitple word objstrs and prepstr
if not command:
return
@ -91,7 +116,6 @@ class Interpreter(object):
cmdmatches = player.location.get_command_matches(command)
else:
raise PyooVerbNotFound
cmd = cmdmatches[0]
# glob = cmd[0]
# comps = cmd[1]

@ -1,7 +1,10 @@
"""PlaceLoader is a system to load simple plain text data to build a room-based space."""
from enum import Enum
from typing import TextIO
from typing import Dict, List, TextIO, Type
from .things import Place, Thing
from .things import Place
from .interpret import Interpreter
class PlaceLoaderStates(Enum):
@ -10,31 +13,53 @@ class PlaceLoaderStates(Enum):
EXITS = 2
class PlaceLoader(object):
class PlaceLoader:
"""
A factory which loads a simple data format containing a description of rooms, and how
they are connected, and produces room objects for each one.
The format is a plain text file containing a series of room definitions:
ROOMNAME
DESCRIPTION (multiple lines)
.
direction,alias ROOMNAME2
.
ROOMNAME2 ...
"""
def __init__(self, in_file: TextIO, baseplace: Thing = Place):
"""Initialize the factory by loading a description file."""
def __init__(self, in_file: TextIO, baseplace: Type[Place] = Place):
"""Initialize the factory by loading a description file.
Arguments:
in_file: A file to load the places from.
baseplace: A type to initialize the rooms from.
"""
self.base = baseplace
self.places = {} # indexed by name
self.places: Dict[str, Place] = {} # indexed by name
if in_file:
self.process_file(in_file)
def process_file(self, in_file: TextIO):
"""Load any room information from the passed in file."""
def process_file(self, in_file: TextIO) -> None:
"""Load any room information from the passed in file.
Arguments:
in_file: The file to load the rooms from.
"""
state = PlaceLoaderStates.START
rm = None
desc = []
desc: List[str] = []
temp_exits: Dict[Place, List[str]] = {}
for line in in_file:
line = line.strip()
if state == PlaceLoaderStates.START:
rm = self.base(line)
rm.temp_exits = list()
temp_exits[rm] = []
state = PlaceLoaderStates.DESC
desc = list()
desc = []
elif state == PlaceLoaderStates.DESC:
if line == ".":
state = PlaceLoaderStates.EXITS
@ -45,14 +70,18 @@ class PlaceLoader(object):
if line == ".":
state = PlaceLoaderStates.START
self.places[rm.name] = rm
rm = None
else:
rm.temp_exits.append(line)
temp_exits[rm].append(line)
# assemble the exits
for place in list(self.places.values()):
for ext in place.temp_exits:
for ext in temp_exits[place]:
names, destination = ext.split(" ", 1)
for nm in names.split(","):
place.ways[nm] = self.places[destination]
place.update_go()
def interpreter_from_placeloader(placeloader: PlaceLoader) -> Interpreter:
"""Return an interpreter intialized with the contents of a placeloader."""
return Interpreter(list(placeloader.places.values()))

@ -1,23 +1,36 @@
"""Base (Pyoo) classes which implement basic protocols."""
import fnmatch
import itertools
from .base import make_verb, PyooVerbNotFound, PyooObjectNotFound
from typing import cast, List, Optional, Tuple, Set, Dict
from .base import make_verb, PyooVerbNotFound, InterpreterProtocol, SingleMultiString, Verb, VerbCallFrame
class Thing:
"""The base of all Pyoo objects."""
def __init__(self, name: str, description: SingleMultiString = "A nondescript object."):
"""Create a Thing
class Thing(object):
def __init__(self, thingname, description="A nondescript object."):
names = [x.strip() for x in thingname.split(",")]
Arguments:
name (str): A string of comma-separated names for this object.
description (str): A string that describes this object.
"""
names = [x.strip() for x in name.split(",")]
self.name = names[0]
self.names = tuple(names)
self.names: Tuple[str, ...] = tuple(names)
self.description = description
self.location = None
self.interpreter = None
self.location: Optional["Container"] = None
self.interpreter: Optional[InterpreterProtocol] = None
def tell(self, message):
def tell(self, message: SingleMultiString) -> None:
print("<tell stub>", self, message)
def verbs(self):
def verbs(self) -> List[Verb]:
"""Return a list of bound methods which denote themselves as verbs."""
verbs = list()
verbs: List[Verb] = []
for item in dir(self):
try:
v = self.__getattribute__(item)
@ -27,20 +40,20 @@ class Thing(object):
continue
return verbs
def verb_globs(self):
def verb_globs(self) -> List[Tuple[str, Tuple, Verb, "Thing"]]:
"""Return a list of (globstr, bound method) where commands matching globstr should call method (given that
'that' matches an object in the soup).
"""
verbglobs = list()
verbglobs: List[Tuple[str, Tuple, Verb, "Thing"]] = []
for vrb in self.verbs():
vvars = [vrb.names]
vvars: List[Tuple] = [tuple(vrb.names)]
if vrb.callspec[0] == "this":
vvars.append(self.names)
elif vrb.callspec[0] in ("that", "any"):
vvars.append(("*",))
if vrb.callspec[1] != "none":
vvars.append(vrb.callspec[1])
if vrb.callspec[1] != ["none"]:
vvars.append(tuple(vrb.callspec[1]))
if vrb.callspec[2] == "this":
vvars.append(self.names)
@ -50,25 +63,45 @@ class Thing(object):
for combo in itertools.product(*vvars):
globstr = " ".join(combo)
verbglobs.append((globstr, tuple(combo), vrb, self))
return verbglobs
def handle_move(self, newlocation):
def handle_move(self, newlocation: "Container") -> None:
"""Handle moving this object to a new location.
Acts as a way for children to hook this occurance too.
"""
self.location = newlocation
def handle_remove(self, oldlocation):
def handle_remove(self, oldlocation: "Container") -> None:
"""Handle removing this object from a container.
Acts as a way for children to hook this occurance too.
"""
self.location = None
def __repr__(self):
def __repr__(self) -> str:
return "<Thing '%s' object at 0x%x>" % (self.name, self.__hash__())
class Container(Thing):
def __init__(self, names, description=""):
"""A Pyoo object which contains other Pyoo objects."""
def __init__(self, names: str, description: SingleMultiString = ""):
"""Create a Container.
Arguments:
names (str): A comma-separated list of names.
description (str): A description for this object.
"""
super().__init__(names, description)
self.contents = set()
self.name_cache = []
self.command_cache = []
self.contents: Set[Thing] = set()
self.name_cache: List[Tuple[str, Thing]] = []
self.command_cache: List[Tuple[str, Tuple, Verb, Thing]] = []
def update_caches(self):
def update_caches(self) -> None:
"""Update the internal cache of contained object's verbs and names."""
self.name_cache = []
self.command_cache = []
for obj in self.contents:
@ -80,10 +113,19 @@ class Container(Thing):
self.command_cache.append(verbglob)
for verbglob in self.verb_globs():
if verbglob[0][0] == "#":
continue
continue
self.command_cache.append(verbglob)
def get_command_matches(self, command_spec):
def get_command_matches(self, command_spec: str) -> List[Tuple[str, Tuple, Verb, Thing]]:
"""Return a list of commands which match a command_spec ordered by specificity.
Arguments:
command_spec (str): A command
Returns:
array[tuple]: A list of command specifiers.
"""
res = [x for x in self.command_cache if fnmatch.fnmatch(command_spec, x[0])]
# sort by ambiguity (percentage of *)
res.sort(key=lambda a: a[0].count("*") / float(len(a[0])))
@ -91,62 +133,94 @@ class Container(Thing):
raise PyooVerbNotFound
return res
def get_name_matches(self, name):
def get_name_matches(self, name: str) -> List[Tuple[str, Thing]]:
"""Return a list of objects which match a name spec.
Arguments:
name (str): A name of an object
Returns:
array[tuple]: A list of name,thing tuples.
"""
return [x for x in self.name_cache if fnmatch.fnmatch(name, x[0])]
def handle_exit(self, oldobj):
def handle_exit(self, oldobj: Thing) -> None:
"""Handle an object leaving the container.
This allows children to hook this occurence also.
Arguments:
oldobj (`pyoo.things.Thing`): The object which is exiting.
"""
self.contents.remove(oldobj)
oldobj.handle_remove(self)
def handle_enter(self, newobj):
def handle_enter(self, newobj: Thing) -> None:
"""Handle an object entering the container.
This allows children to hook this occurence also.
Arguments:
newobj (`pyoo.things.Thing`): The object which is entering.
"""
self.contents.add(newobj)
newobj.handle_move(self)
try:
newobj.update_caches()
cast("Container", newobj).update_caches()
except AttributeError:
pass
self.update_caches()
def handle_tell(self, msg, who):
def handle_tell(self, msg: SingleMultiString, who: Set[Thing]) -> None:
"""Handle processing a tell to a list of objects.
"""
for obj in who:
obj.tell(msg)
def tell(self, msg):
def tell(self, msg: SingleMultiString) -> None:
"""Handle telling all content objects."""
self.handle_tell(msg, self.contents)
def __repr__(self):
def __repr__(self) -> str:
return "<Container '%s' object at 0x%x>" % (self.name, self.__hash__())
class Place(Container):
def __init__(self, names, description=""):
"""A specialized container which models a place."""
def __init__(self, names: str, description: str = "") -> None:
"""Create a place."""
super().__init__(names, description)
self.ways = dict()
self.ways: Dict[str, Container] = dict()
def tell_only(self, message, verb_callframe):
def tell_only(self, message: SingleMultiString, verb_callframe: VerbCallFrame) -> None:
self.handle_tell(message, self.contents - {verb_callframe.player})
# this verb expects to be annotated from update_go. We never want it ot be at the top of a match list by its deault
# name either
@make_verb("#go", "none", "none", "none")
def go(self, verb_callframe):
def go(self, verb_callframe: VerbCallFrame) -> None:
self.do_go(verb_callframe.verbname, verb_callframe)
@make_verb("go,move,walk,run", "any", "none", "none")
def go_dir(self, verb_callframe):
def go_dir(self, verb_callframe: VerbCallFrame) -> None:
self.do_go(verb_callframe.dobjstr, verb_callframe)
def do_go(self, direction, verb_callframe):
def do_go(self, direction: str, verb_callframe: VerbCallFrame) -> None:
if direction in self.ways:
self.tell_only("{} moves {}".format(verb_callframe.player, direction), verb_callframe)
self.tell_only("{} moves {}".format(verb_callframe.player.name, direction), verb_callframe)
verb_callframe.player.tell("You move {}".format(direction))
verb_callframe.environment.handle_move(self.ways[direction], verb_callframe.player)
def handle_enter(self, newobj):
def handle_enter(self, newobj: Thing) -> None:
super().handle_enter(newobj)
self.tell("{} arrives".format(newobj.name))
self.handle_tell("{} arrives".format(newobj.name), self.contents - {newobj})
def update_go(self):
def update_go(self) -> None:
# note does no actually remove items from the go verb in case the descender is overloading.
# also note, the interpreter needs to have update() called after this is called.
for direction in self.ways:
@ -155,13 +229,13 @@ class Place(Container):
if self.interpreter:
self.interpreter.update()
def __repr__(self):
def __repr__(self) -> str:
return "<Place '%s' object at 0x%x>" % (self.name, self.__hash__())
class Player(Container):
def __init__(self, names, description=""):
def __init__(self, names: str, description: str = ""):
super().__init__(names, description)
def __repr__(self):
def __repr__(self) -> str:
return "<Player '%s' object at 0x%x>" % (self.name, self.__hash__())

@ -1,8 +1,9 @@
from pyoo.things import Place, Player
from pyoo.placeloader import PlaceLoader
from pyoo.interpret import Interpreter, PyooVerbNotFound
from pyoo.placeloader import interpreter_from_placeloader, PlaceLoader
from pyoo.interpret import PyooVerbNotFound
from pyoo.base import make_verb
class DescriptivePlace(Place):
def handle_enter(self, player):
super().handle_enter(player)
@ -23,7 +24,7 @@ class DescriptivePlace(Place):
loader = PlaceLoader(open("roomtest.txt", "r"), DescriptivePlace)
player = Player("player")
game = Interpreter(list(loader.places.values()))
game = interpreter_from_placeloader(loader)
porch = game.lookup_global_object("Porch")[0][1]
run = True
game.update()

@ -2,6 +2,7 @@ from pyoo.interpret import Interpreter
from pyoo.things import Thing, Place, Player
from pyoo.base import make_verb, PyooVerbNotFound
class Hammer(Thing):
def __init__(self):
Thing.__init__(self, "hammer", "a heavy ball-peen hammer.")
@ -40,13 +41,13 @@ class Nail(Thing):
else:
return "You see a nail fully hammered in."
class HammerTime(Place):
def __init__(self):
Place.__init__(self, "HAMMERTIME")
self.handle_enter(Hammer())
self.handle_enter(Nail())
@make_verb("look,l", "none", "none", "none")
def look(self, verb_callframe):
for cont in self.contents:

@ -1,12 +1,14 @@
[tox]
envlist = py36, py37
envlist = py36, py37, py38, py39
[testenv]
deps =
flake8
mypy
commands = flake8
commands =
flake8
mypy -p pyoo
[flake8]
max-line-length = 120
max-complexity = 10
max-complexity = 15
Loading…
Cancel
Save