mwchase icon

LC:Nobilis Update 4: diff

mwchase | PRO | 05/21/17 09:58:37 PM UTC | 0 ⭐ | 1426 👁️ | Never ⏰ | []
Diff |

24 KB

|

None

|

0 👍

/

0 👎

diff --git a/.coveragerc b/.coveragerc
new file mode 100644
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,2 @@
+[run]
+branch = True
diff --git a/.hgignore b/.hgignore
new file mode 100644
--- /dev/null
+++ b/.hgignore
@@ -0,0 +1,96 @@
+syntax: glob
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.cache
+nosetests.xml
+coverage.xml
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+
+# cache files for sublime text
+*.tmlanguage.cache
+*.tmPreferences.cache
+*.stTheme.cache
+
+# workspace files are user-specific
+*.sublime-workspace
+
+# project files should be checked into the repository, unless a significant
+# proportion of contributors will probably not be using SublimeText
+# *.sublime-project
+
+# sftp configuration file
+sftp-config.json
+
+
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear on external disk
+.Spotlight-V100
+.Trashes
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+
diff --git a/README.rst b/README.rst
new file mode 100644
diff --git a/data.py b/prototype/data.py
rename from data.py
rename to prototype/data.py
diff --git a/nobilis.py b/prototype/nobilis.py
rename from nobilis.py
rename to prototype/nobilis.py
diff --git a/setup.py b/setup.py
new file mode 100644
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,47 @@
+"""A setuptools based setup module."""
+
+# Always prefer setuptools over distutils
+from setuptools import setup, find_packages
+# To use a consistent encoding
+from codecs import open as c_open
+from os import path
+
+HERE = path.abspath(path.dirname(__file__))
+
+# Get the long description from the README file
+with c_open(path.join(HERE, 'README.rst'), encoding='utf-8') as f:
+    LONG_DESCRIPTION = f.read()
+
+setup(
+    name='nobilis',
+
+    version='0.1.0',
+
+    description='Nobilis character creator',
+    long_description=LONG_DESCRIPTION,
+
+    # url='https://github.com/mwchase/class-namespaces',
+
+    author='Max Woerner Chase',
+    author_email='[email protected]',
+
+    license='MIT',
+
+    classifiers=[
+        'Development Status :: 3 - Alpha',
+
+        'License :: OSI Approved :: MIT License',
+
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.6',
+    ],
+
+    keywords='nobilis',
+
+    packages=find_packages('src', exclude=['contrib', 'docs', 'tests']),
+    package_dir={'': 'src'},
+
+    extras_require={
+        'test': ['coverage', 'pytest'],
+    },
+)
diff --git a/src/nobilis/__init__.py b/src/nobilis/__init__.py
new file mode 100644
diff --git a/src/nobilis/game_data.py b/src/nobilis/game_data.py
new file mode 100644
--- /dev/null
+++ b/src/nobilis/game_data.py
@@ -0,0 +1,265 @@
+import collections
+
+
+KeyType = collections.namedtuple('KeyType', 'strength weakness')
+
+POWER_KEY = KeyType('heart', 'shadow')
+DECEIVER_KEY = KeyType('lie', 'truth')
+
+KeyTemplate = collections.namedtuple(
+    'KeyTemplate',
+    'type flower key_name names strength_hint weakness_bullets')
+
+
+POWER_TEMPLATES = ()
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Lotus Flower', 'Key of the Descending Angel',
+    ('My Power', 'My Nature'),
+    "how you enact meaningful change in people's lives",
+    ('glorious', 'set apart')),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Vervain', 'Key of Something Powerful',
+    ('My Identity', 'Burdens'), 'who you are when you wield power',
+    ('bound by duty', 'set apart')),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Acacia', 'Key of Something that Must Be Hidden',
+    ('A Dangerous Secret', '...That Hurts To Keep'),
+    'what you do when dealing with the secret',
+    ('painful silence', 'set apart')),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Clematis', 'Key of Something, Given Gifts',
+    ('I Have Witnessed It', 'Why I Doubt'),
+    'who you are when you are witness to grace', ()),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Aconite', 'Key of Rage',
+    # Not sure how best to represent this.
+    ("(.*'s )?(Rage|Hatred|Fury)", 'What Lets Me Oppose It'),
+    'who you are in this drama', ()),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Wild Oats', 'Key of Destiny Denied',
+    ('Hunger', 'Why It Feels Empty'),
+    'who you are when indulging in your vice', ()),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Star of Bethlehem', 'Key of Something, Changed Forever',
+    ('What it Made Me', 'How It Changed the World'),
+    "who you are when you're thinking about the mystery",
+    ("I can't let go", "I'm committed")),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Gorse', 'Key of Something in Thrall',
+    ('Held in Thrall', 'Entangled with my Enemy'),
+    'how you are held in thrall, and by what',
+    ("I can't let go", "I'm weak")),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Honeysuckle', 'Key of Something, Finally, at Rest',
+    ('My New Life', 'My Old Life'), 'who you are and who you were', ()),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Chamomile', 'Key of Something Romantic',
+    ('The Dream', 'The Cost'), 'who you are', ()),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Wild Rose', 'Key of Something Different',
+    ('My Nature', 'My Struggles'), 'who you are compared to others',
+    ('self-doubt', 'trying to fit in')),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Hollyhock', 'Key of Destiny in the Balance',
+    ('My Destiny', 'My Struggles'), 'what you are doing as you change',
+    ('self-doubt', 'changing hurts')),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Mimulus', 'Key of Something Restless',
+    ('My Path', 'Why it Feels Empty'), 'who you are when you wander',
+    ('self-doubt', "the things I've left behind")),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Water Lily', 'Key of Something Spiritual',
+    ('My Retreat', 'Burdens'),
+    'who you are when you separate yourself from the world',
+    ('my duties as a Power',)),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Oak', "Key of Something That Hasn't Changed",
+    ('My Identity', 'Crisis'), 'who you are', ()),
+POWER_TEMPLATES += KeyTemplate(
+    POWER_KEY, 'Alyssym', 'Key of Destiny Fulfilled',
+    ('My Destiny', None), 'who you are, in terms of who you were', ()),
+
+DECEIVER_TEMPLATES = ()
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Lotus Flower', 'Key of the Shepherd', 'I BRING MY FLOCK...',
+    'what people get out of interacting with you',
+    ('I love them', 'I listen to them')),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Vervain', 'Key of the Dragon', 'THE EXCRUCIAN WAR',
+    'cool stuff that you could do or have done',
+    ('I love them', 'I am their enemy')),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Acacia', 'Key of the Crossroads', 'WHY I FALTER',
+    'your excuse for being kind to mortals and Powers',
+    ('I love them', "I've lost sight of what I'm fighting for")),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Clematis', 'Key of the Angel', "I'M REACHING OUT TO YOU",
+    "who you pretend to be when you're reaching out to the Powers",
+    ()),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Aconite', 'Key of the Nemesis', 'THE EXCRUCIAN WAR',
+    "who you are when ruining a Power's life", ()),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Wild Oats', 'Key of the Dreamer',
+    'WHO I AM, IN THIS YOUR DREAM',
+    'the role you take on in this dream', ()),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Star of Bethlehem', 'Key of Burdens', 'IT MARKED ME',
+    "what you do to show you're carrying something with you",
+    ("I can't let go", "It's always with me")),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Gorse', 'Key of Promises', 'HELD IN THRALL',
+    "the situation you're trapped in", ("I can't let go", "It's killing me")),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Periwinkle', 'Key of Legends', 'MY LEGEND',
+    'an impressive thing you did', ()),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Chamomile', 'Key of the Mask',
+    'WHO I AM, IN THIS YOUR DREAM', 'who you are', ()),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Widow-Rose', 'Key of the Alien', 'MY NATURE',
+    'why you cannot fit in',
+    ("I'm an enemy of the world", 'You cannot keep me away')),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Milkmay', 'Key Unformed', 'THAT WHICH STIRS',
+    "what form you're likely to take when play begins",
+    ("I'm an enemy of the world", 'I am truth preceding form')),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Phoenix Posy', 'Self-Creating Key, the',
+    'I HAVE MADE MYSELF...', 'how you fit into the world',
+    ("I'm an enemy of the world", 'from nothing')),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Water Lily', 'Key of Something Spiritual', 'MY NATURE',
+    'how your holiness expresses itself in the world',
+    ('Excrucian Deceiver',)),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Bilberry', 'Key of the Fallen', 'WHAT I BELIEVE',
+    "what you've accepted", ()),
+DECEIVER_TEMPLATES += KeyTemplate(
+    DECEIVER_KEY, 'Hemlock', 'Key of the Lie', 'WHO I AM, IN THIS YOUR DREAM',
+    "the story you're trying to tell (often part of another Key)",
+    ()),
+
+
+GeneralTemplate = collections.namedtuple('GeneralTemplate', 'name indices')
+
+
+FOUNDATION_TEMPLATES = ()
+FOUNDATION_TEMPLATES += GeneralTemplate(
+    'Something Cool', (1, 2, 10, 15)),
+FOUNDATION_TEMPLATES += GeneralTemplate(
+    'In Love with Something', (4, 7, 9, 16)),
+FOUNDATION_TEMPLATES += GeneralTemplate(
+    'Epic, Inhuman, and Powerful', (5, 6, 12, 14)),
+FOUNDATION_TEMPLATES += GeneralTemplate(
+    'Just Plain Weird', (3, 8, 11, 13)),
+
+ESTATE_SOURCE_TEMPLATES = ()
+ESTATE_SOURCE_TEMPLATES += GeneralTemplate(
+    'Light Side of Human Experience', (2, 6, 10, 12)),
+ESTATE_SOURCE_TEMPLATES += GeneralTemplate(
+    'Dark Side of Human Experience', (3, 5, 11, 15)),
+ESTATE_SOURCE_TEMPLATES += GeneralTemplate(
+    'Beautiful Side of the World', (4, 7, 14, 16)),
+ESTATE_SOURCE_TEMPLATES += GeneralTemplate(
+    'Painful Side of the World', (1, 8, 9, 13)),
+
+ESTATE_NATURE_TEMPLATES = ()
+ESTATE_NATURE_TEMPLATES += GeneralTemplate(
+    'Something You Can Point To', (8, 9, 13, 14, 15)),
+ESTATE_NATURE_TEMPLATES += GeneralTemplate(
+    'Something You Live', (3, 5, 6, 4, 11, 12)),
+ESTATE_NATURE_TEMPLATES += GeneralTemplate(
+    'Something You Can Describe', (1, 2, 7, 10, 16)),
+
+ORIGIN_TEMPLATES = ()
+ORIGIN_TEMPLATES += GeneralTemplate('Troubled Life', (2, 5, 6, 11)),
+ORIGIN_TEMPLATES += GeneralTemplate('Humble Life', (8, 9, 14, 15)),
+ORIGIN_TEMPLATES += GeneralTemplate('Blessed Life', (1, 4, 12, 16)),
+ORIGIN_TEMPLATES += GeneralTemplate('Extraordinary Life', (3, 7, 10, 13)),
+
+TROUBLED_TEMPLATES = ()
+TROUBLED_TEMPLATES += GeneralTemplate(
+    "You're Still in Trouble!", (3, 5, 8, 13)),
+TROUBLED_TEMPLATES += GeneralTemplate(
+    'Some Scars Remain', (7, 11, 12, 14)),
+TROUBLED_TEMPLATES += GeneralTemplate(
+    "It's All Happening Again!", (6, 10, 15, 16)),
+TROUBLED_TEMPLATES += GeneralTemplate(
+    'Trouble Inspired Me', (1, 2, 4, 9)),
+
+HUMBLE_TEMPLATES = ()
+HUMBLE_TEMPLATES += GeneralTemplate(
+    'Love for the Ordinary...', (6, 7, 15, 16)),
+HUMBLE_TEMPLATES += GeneralTemplate(
+    'Alienation...', (3, 5, 11, 14)),
+HUMBLE_TEMPLATES += GeneralTemplate(
+    'Transformation...', (4, 8, 12, 13)),
+HUMBLE_TEMPLATES += GeneralTemplate(
+    'Freedom!', (1, 2, 9, 10)),
+
+BLESSED_TEMPLATES = ()
+BLESSED_TEMPLATES += GeneralTemplate('Reverence in Purpose', (2, 4, 10, 13)),
+BLESSED_TEMPLATES += GeneralTemplate('Community', (1, 3, 8, 15)),
+BLESSED_TEMPLATES += GeneralTemplate('Your Way of Life', (7, 9, 14, 16)),
+BLESSED_TEMPLATES += GeneralTemplate('Anger', (5, 6, 9, 12)),
+
+NORMAL_CONTACT_TEMPLATES = ()
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Organization', (1,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Followers', (2,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Excrucian', (3,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Inspirational Friend or Lover', (4,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Nemesis', (5,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Corrupting Influence', (6,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Ghost', (7,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Cammora', (8,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Legacy', (9,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'True Love', (10,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Alien(s)', (11,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Manufactured Army', (12,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Faraway or Troubled Love', (13,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Ward', (14,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Moral Friends and Family', (15,)),
+NORMAL_CONTACT_TEMPLATES += GeneralTemplate(
+    'Disciples', (16,)),
+
+SPECIAL_CONTACT_TEMPLATES = ()
+SPECIAL_CONTACT_TEMPLATES += GeneralTemplate('Noble Friend or Enemy', ()),
+SPECIAL_CONTACT_TEMPLATES += GeneralTemplate('Cleave of the Botanists', ()),
+SPECIAL_CONTACT_TEMPLATES += GeneralTemplate('Mystery Cult', ()),
+
+
+AFFLIATION_TEMPLATES = ()
+AFFLIATION_TEMPLATES += GeneralTemplate(
+    '(The Song of) Heaven', (1, 2, 3)),
+AFFLIATION_TEMPLATES += GeneralTemplate(
+    '(The Song of) Hell', (4, 5, 6)),
+AFFLIATION_TEMPLATES += GeneralTemplate(
+    '(The Song of) The Light', (7, 8)),
+AFFLIATION_TEMPLATES += GeneralTemplate(
+    '(The Song of) The Dark', (9, 10)),
+AFFLIATION_TEMPLATES += GeneralTemplate(
+    '(The Song of) The Wild', (11, 12, 13)),
+AFFLIATION_TEMPLATES += GeneralTemplate(
+    'An Independent Song', (14, 15, 16)),
+
+
+BACKGROUND_TEMPLATES = ()
+BACKGROUND_TEMPLATES += GeneralTemplate('Animal', (6, 8, 14, 15)),
+BACKGROUND_TEMPLATES += GeneralTemplate('Strange', (4, 11, 12, 16)),
+BACKGROUND_TEMPLATES += GeneralTemplate('"Sort of" Human', (1, 7, 10, 13)),
diff --git a/src/nobilis/lifepath.py b/src/nobilis/lifepath.py
new file mode 100644
--- /dev/null
+++ b/src/nobilis/lifepath.py
@@ -0,0 +1,62 @@
+import collections
+
+from . import visitor
+
+
+nt = collections.namedtuple
+
+Business = visitor.make_visitor()
+
+
+def key(item):
+    return type(item), item
+
+
+class Parentable:
+
+    __slots__ = ()
+
+
+P = Parentable
+
+
[email protected]
+class Choice(P, nt('Choice', 'template')):
+
+    __slots__ = ()
+
+
[email protected]
+class ChoiceDisplay(P, nt('ChoiceDisplay', 'choice')):
+
+    __slots__ = ()
+
+
[email protected]
+class ChoiceTaken(P, nt('ChoiceTaken', 'choice')):
+
+    __slots__ = ()
+
+
[email protected]
+class ChoiceListed(P, nt('ChoiceListed', 'choice')):
+
+    __slots__ = ()
+
+
[email protected]
+class ChoiceList(P, nt('ChoiceList', 'choices initial_min initial_max')):
+
+    __slots__ = ()
+
+
[email protected]
+class DisplayList(P, nt('DisplayList', 'factory choices_ sections_')):
+
+    __slots__ = ()
+
+
[email protected]
+class Section(P, nt('Section', 'display_list name')):
+
+    __slots__ = ()
diff --git a/src/nobilis/visitor.py b/src/nobilis/visitor.py
new file mode 100644
--- /dev/null
+++ b/src/nobilis/visitor.py
@@ -0,0 +1,88 @@
+"""This module provides an abstract class for visitors."""
+
+import functools
+
+SENTINEL = object()
+
+
+def visitor_func(self, node, function, old_visitor):
+    def next_visitor(arg=None):
+        return function(self, node, old_visitor)
+    return next_visitor
+
+
+def identity(arg=None):
+    return arg
+
+
+def make_visitor(default=None):
+    """Return a base Visitor class."""
+    class Visitor:
+
+        __classes = set()
+
+        def __new__(cls, *args, **kwargs):
+            if cls is Visitor:
+                raise TypeError
+            return super().__new__(cls, *args, **kwargs)
+
+        def __init_subclass__(cls, **kwargs):
+            super().__init_subclass__(**kwargs)
+            cls.__visitors = {}
+
+        if default is not None:
+            default_visit = default
+        else:
+            def default_visit(self, node):
+                raise NotImplementedError(
+                    f'No implementation for {node} on {self}.')
+
+        def __get_visitor(self, node_class):
+            my_mro = type(self).mro()
+            visitor_index = my_mro.index(Visitor)
+            my_mro = my_mro[:visitor_index]
+            visitor_mro = []
+            for cls in node_class.mro():
+                for visitor_class in my_mro:
+                    try:
+                        visitor_dict = visitor_class.__visitors
+                    except AttributeError:
+                        pass
+                    else:
+                        visitor = visitor_dict.get(cls, SENTINEL)
+                        if visitor is not SENTINEL:
+                            visitor_mro.append(visitor)
+            return visitor_mro
+
+        def visit(self, node):
+            visitors = self.__get_visitor(type(node))
+            if visitors:
+                visitor = identity
+                while visitors:
+                    visitor = visitor_func(self, node, visitors.pop(), visitor)
+                return visitor()
+            else:
+                return self.default_visit(node)
+
+        @classmethod
+        def bind(cls, node_class, function=None):
+            if cls is Visitor:
+                raise NotImplementedError
+            if node_class not in cls.__classes:
+                raise ValueError
+            if function is None:
+                return functools.partial(cls.bind, node_class)
+            cls.__visitors[node_class] = function
+            return cls
+
+        @classmethod
+        def mark(cls, node_class):
+            cls.__classes.add(node_class)
+            return node_class
+
+        @classmethod
+        def undefined(cls):
+            if cls is Visitor:
+                raise ValueError
+            return cls.__classes.difference(cls.__visitors)
+    return Visitor
diff --git a/tests/nobilis/__init__.py b/tests/nobilis/__init__.py
new file mode 100644
diff --git a/tests/nobilis/conftest.py b/tests/nobilis/conftest.py
new file mode 100644
--- /dev/null
+++ b/tests/nobilis/conftest.py
@@ -0,0 +1,19 @@
+import pytest
+
+
[email protected]
+def visitor():
+    import nobilis.visitor
+    return nobilis.visitor
+
+
[email protected]
+def game_data():
+    import nobilis.game_data
+    return nobilis.game_data
+
+
[email protected]
+def lifepath():
+    import nobilis.lifepath
+    return nobilis.lifepath
diff --git a/tests/nobilis/test_game_data.py b/tests/nobilis/test_game_data.py
new file mode 100644
--- /dev/null
+++ b/tests/nobilis/test_game_data.py
@@ -0,0 +1,2 @@
+def test_import(game_data):
+    assert game_data
diff --git a/tests/nobilis/test_lifepath.py b/tests/nobilis/test_lifepath.py
new file mode 100644
--- /dev/null
+++ b/tests/nobilis/test_lifepath.py
@@ -0,0 +1,7 @@
+def test_import(lifepath):
+    assert lifepath
+
+
+def test_key(lifepath):
+    assert lifepath.key(lifepath.Choice(None)) != lifepath.key(
+        lifepath.ChoiceDisplay(None))
diff --git a/tests/nobilis/test_visitor.py b/tests/nobilis/test_visitor.py
new file mode 100644
--- /dev/null
+++ b/tests/nobilis/test_visitor.py
@@ -0,0 +1,149 @@
+import pytest
+
+
+def test_import(visitor):
+    assert visitor
+
+
+def test_visitor(visitor):
+    assert visitor.make_visitor()
+
+
+def test_mark(visitor):
+    Visitor = visitor.make_visitor()
+
+    @Visitor.mark
+    class Test:
+        pass
+
+    class Test2:
+        pass
+
+    Test3 = Visitor.mark(Test2)
+    assert Test2 is Test3
+
+
+def test_cannot_instantiate_base_class(visitor):
+    Visitor = visitor.make_visitor()
+
+    with pytest.raises(TypeError):
+        print(Visitor())
+
+    class Subclass(Visitor):
+        pass
+
+    assert Subclass()
+
+
+def test_bind(visitor):
+    Visitor = visitor.make_visitor()
+
+    @Visitor.mark
+    class Test:
+        pass
+
+    class Subclass(Visitor):
+        pass
+
+    with pytest.raises(ValueError):
+        print(Visitor.undefined())
+
+    assert Subclass.undefined() == {Test}
+
+    with pytest.raises(NotImplementedError):
+        Visitor.bind(Test)
+
+    with pytest.raises(NotImplementedError):
+        Subclass().visit(Test())
+
+    @Subclass.bind(Test)
+    def Subclass(self, node, next_visitor):
+        return node
+
+    assert Subclass.undefined() == set()
+
+    test = Test()
+    assert Subclass().visit(test) is test
+
+
+def test_provide_default(visitor):
+    @visitor.make_visitor
+    def Visitor(self, node):
+        return node
+
+    @Visitor.mark
+    class Test:
+        pass
+
+    class Subclass(Visitor):
+        pass
+
+    test = Test()
+    assert Subclass().visit(test) is test
+
+
+def test_cannot_add_unmarked(visitor):
+    Visitor = visitor.make_visitor()
+
+    class Subclass(Visitor):
+        pass
+
+    with pytest.raises(ValueError):
+        Subclass.bind(object)
+
+    Visitor.mark(object)
+
+    assert Subclass.bind(object)
+
+
+def test_multiple_inheritance(visitor):
+    Visitor = visitor.make_visitor()
+
+    class Klass:
+        pass
+
+    class Subclass(Klass, Visitor):
+        pass
+
+    @Visitor.mark
+    class Test:
+        pass
+
+    with pytest.raises(NotImplementedError):
+        Subclass().visit(Test())
+
+
+def test_next_visitor(visitor):
+    Visitor = visitor.make_visitor()
+
+    @Visitor.mark
+    class Test1:
+        pass
+
+    @Visitor.mark
+    class Test2(Test1):
+        pass
+
+    class Visitor1(Visitor):
+        pass
+
+    class Visitor2(Visitor1):
+        pass
+
+    @Visitor2.bind(Test2)
+    def Visitor2(self, node, next_visitor):
+        return 'a' + next_visitor('f')
+
+    @Visitor1.bind(Test2)
+    def Visitor1(self, node, next_visitor):
+        return 'b' + next_visitor('f')
+
+    @Visitor2.bind(Test1)
+    def Visitor2(self, node, next_visitor):
+        return 'c' + next_visitor('f')
+
+    @Visitor1.bind(Test1)
+    def Visitor1(self, node, next_visitor):
+        return 'd' + next_visitor('e')
+
+    assert Visitor2().visit(Test2()) == 'abcde'
diff --git a/tests/test_nobilis.py b/tests/test_nobilis.py
new file mode 100644
--- /dev/null
+++ b/tests/test_nobilis.py
@@ -0,0 +1,3 @@
+def test_import_nobilis():
+    import nobilis
+    assert nobilis
diff --git a/tox.ini b/tox.ini
new file mode 100644
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,17 @@
+# Tox (http://tox.testrun.org/) is a tool for running tests
+# in multiple virtualenvs. This configuration file will run the
+# test suite on all supported python versions. To use it, "pip install tox"
+# and then run "tox" from this directory.
+
+[tox]
+envlist = py36
+
+[testenv]
+commands =
+    {envpython} -m divide_and_cover run -m pytest --divide-and-cover
+    {envpython} -m divide_and_cover html -d htmlcov/{envname}
+deps =
+    pytest-divide-and-cover
+    divide-and-cover
+    pytest
+    coverage

Comments