diff --git a/data.py b/data.py
new file mode 100644
--- /dev/null
+++ b/data.py
@@ -0,0 +1,379 @@
+import collections
+import functools
+import string
+
+
+ALLOWED_CHARS = set(string.letters + string.digits + '_')
+SENTINEL = object()
+PARENT = {}
+
+
+def visitor_name(cls):
+ node_type = getattr(cls, 'node_type', None)
+ if node_type is None:
+ raise ValueError(f'No node_type defined for {cls}')
+ return f'visit_{node_type}'
+
+
+class NodeMeta(type):
+
+ def __new__(mcs, name, bases, namespace, **kwargs):
+ # Not sure this doesn't blow up.
+ cls = super().__new__(mcs, name, bases, namespace, **kwargs)
+ node_type = getattr(cls, 'node_type', SENTINEL)
+ if node_type is not SENTINEL:
+ if node_type is None:
+ return cls
+ if node_type in cls._types:
+ raise ValueError(f'Duplicate node type: {node_type!r}')
+ if not ALLOWED_CHARS.issuperset(node_type):
+ raise ValueError(f'Impolite node type: {node_type!r}')
+ cls._types.add(node_type)
+ _visitor_name = visitor_name(cls)
+
+ def dispatch(self, visitor):
+ return getattr(visitor, _visitor_name)(self)
+ setattr(cls.Visitor, _visitor_name, cls.Visitor.default_visit)
+ else:
+ cls.node_type = None
+ cls._types = set()
+
+ def dispatch(self, methods):
+ raise NotImplementedError(
+ 'Subclasses should auto-generate this.')
+
+ class Visitor:
+
+ def default_visit(self, node):
+ raise NotImplementedError(
+ f'No implementation for {node} on {self}.')
+
+ @classmethod
+ def bind(cls_, node_class, function=SENTINEL):
+ if cls_ is cls.Visitor:
+ raise ValueError('Cannot bind base Visitor class.')
+ if function is SENTINEL:
+ return functools.partial(cls_.bind, node_class)
+ setattr(cls_, visitor_name(node_class), function)
+ return cls_
+
+ @classmethod
+ def undefined(cls_):
+ if cls_ is cls.Visitor:
+ raise ValueError(
+ 'No point checking undefined on base Visitor '
+ 'class.')
+ default = cls.Visitor.default_visit
+ return {attr for attr in vars(cls.Visitor) if
+ attr.startswith('visit_') and
+ default == getattr(cls_, attr)}
+
+ cls.Visitor = Visitor
+
+ cls.dispatch = dispatch
+
+ return cls
+
+
+class BusinessNode(metaclass=NodeMeta):
+
+ __slots__ = ()
+
+ @property
+ def parent(self):
+ return PARENT.get((type(self), self))
+
+
+class CachingMeta(type):
+
+ def __get__(self, instance, owner):
+ if instance is None:
+ return functools.partial(self)
+ else:
+ return functools.partial(self, instance)
+
+
+class Caching(metaclass=CachingMeta):
+
+ set_up = False
+
+ def __new__(cls, instance, node):
+ return instance.setdefault((type(node), node), super().__new__(cls))
+
+ def __init_subclass__(cls, **kwargs):
+ super().__init_subclass__(**kwargs)
+ init = vars(cls).get('__init__')
+ if init is not None:
+ def __init__(self, *args, **kwargs):
+ if self.set_up:
+ return
+ init(self, *args, **kwargs)
+ self.set_up = True
+ cls.__init__ = __init__
+
+
+class StateVisitor(BusinessNode.Visitor, dict):
+
+ __slots__ = ()
+
+
+class UIVisitor(BusinessNode.Visitor, dict):
+
+ __slots__ = 'state',
+
+ def __init__(self, state):
+ super().__init__()
+ self.state = state
+
+
+class Choice(BusinessNode, collections.namedtuple('Choice', 'template')):
+
+ node_type = 'choice'
+
+
+class ChoiceDisplay(BusinessNode,
+ collections.namedtuple('ChoiceDisplay', 'choice')):
+
+ node_type = 'choice_display'
+
+
+class ChoiceTaken(BusinessNode,
+ collections.namedtuple('ChoiceTaken', 'choice')):
+
+ node_type = 'choice_taken'
+
+
+class ChoiceListed(BusinessNode,
+ collections.namedtuple('ChoiceListed', 'choice')):
+
+ node_type = 'choice_listed'
+
+
+class ChoiceList(BusinessNode,
+ collections.namedtuple('ChoiceList',
+ 'choices initial_min initial_max')):
+
+ node_type = 'choice_list'
+
+ def __init__(self, choices, initial_min, initial_max):
+ for choice in choices:
+ key = type(choice), choice
+ if PARENT.setdefault(key, self) != self:
+ for choice in choices:
+ if PARENT.get(key) is self:
+ del PARENT[key]
+ raise ValueError(f'Cannot double-parent a choice: {choice}')
+
+
+class DisplayList(BusinessNode,
+ collections.namedtuple('DisplayList',
+ 'factory _choices _sections')):
+
+ node_type = 'display_list'
+
+ def __init__(self, factory, _choices, sections):
+ for choice in self.choices:
+ key = type(choice), choice
+ if PARENT.setdefault(key, self) != self:
+ for choice in self.choices:
+ if PARENT.get(key) is self:
+ del PARENT[key]
+ raise ValueError(f'Cannot double-parent a choice: {choice}')
+
+ @property
+ def choices(self):
+ for choice in self._choices:
+ yield self.factory(choice)
+
+ @property
+ def sections(self):
+ # This is O(n^2), but n is small
+ for section in self._sections:
+ yield Section(self, section[0])
+
+
+class Section(BusinessNode,
+ collections.namedtuple('Section', 'display_list', 'name')):
+
+ def __init__(self, display_list, name):
+ self.section
+
+ @property
+ def predicate(self):
+ return self.section[1]
+
+ @property
+ def section(self):
+ for section in self.display_list._sections:
+ if section[0] == self.name:
+ return section
+ raise ValueError(
+ f'No section in {self.display_list} matching {self.name}')
+
+
+class StateBase(Caching):
+
+ visible = False
+
+ def __init__(self, instance, node):
+ self.visitor = instance
+ self.node = node
+
+ @property
+ def parent(self):
+ if self.node.parent:
+ return self.node.parent.dispatch(self.visitor)
+
+
+class BusinessButton(Caching):
+
+ @property
+ def is_valid(self):
+ return self.enabled or not self.selected
+
+ @property
+ def visible(self):
+ parent = self.parent
+ return parent is not None and parent.visible
+
+
+class ChoiceButton(BusinessButton):
+
+ @property
+ def choice(self):
+ return self.node.choice.dispatch(self.visitor)
+
+
[email protected](Choice)
+class StateVisitor(Caching):
+
+ is_valid = True
+
+ selected = False
+
+ @property
+ def choice(self):
+ return self.node
+
+
[email protected](ChoiceDisplay)
+class StateVisitor(ChoiceButton):
+
+ enabled = False
+
+ selected = False
+
+ @property
+ def choice_display(self):
+ return self.node
+
+
[email protected](ChoiceTaken)
+class StateVisitor(ChoiceButton):
+
+ @property
+ def choice_taken(self):
+ return self.node
+
+ # Should this go on UI instead?
+ @property
+ def visible(self):
+ return (super().visible and
+ self.choice_taken.choice.dispatch(self.visitor).selected)
+
+
[email protected](ChoiceListed)
+class StateVisitor(ChoiceButton):
+
+ @property
+ def choice_listed(self):
+ return self.node
+
+ @property
+ def selected(self):
+ return self.choice_listed.choice.dispatch(self.visitor).selected
+
+ @property
+ def enabled(self):
+ parent = self.parent
+ if parent:
+ return self.selected or not parent.at_maximum
+ return True
+
+
[email protected](ChoiceList)
+class StateVisitor(Caching):
+
+ def __init__(self, instance, node):
+ super().__init__(instance, node)
+ self.minimum = node.initial_min
+ self.maximum = node.initial_max
+
+ @property
+ def is_valid(self):
+ if self.maximum is not None and self.selected > self.maximum:
+ return False
+ return all(choice.is_valid for choice in self.choices)
+
+ @property
+ def choices(self):
+ for choice in self.choice_list.choices:
+ yield choice.dispatch(self.visitor)
+
+ @property
+ def selected(self):
+ return sum(choice.selected for choice in self.choices)
+
+ @property
+ def choice_list(self):
+ return self.node
+
+ @property
+ def at_minimum(self):
+ return self.selected >= self.minimum
+
+ @property
+ def at_maximum(self):
+ return self.maximum is not None and self.selected >= self.maximum
+
+
[email protected](DisplayList)
+class StateVisitor(Caching):
+
+ @property
+ def display_list(self):
+ return self.node
+
+ @property
+ def is_valid(self):
+ return all(choice.is_valid for choice in self.choices)
+
+ @property
+ def choices(self):
+ for choice in self.display_list.choices:
+ yield choice.dispatch(self.visitor)
+
+ @property
+ def sections(self):
+ for section in self.display_list.sections:
+ yield section.dispatch(self.visitor)
+
+
[email protected](Section)
+class StateVisitor(Caching):
+
+ @property
+ def section(self):
+ return self.node
+
+ @property
+ def name(self):
+ return self.section.name
+
+ @property
+ def choices(self):
+ predicate = self.section.predicate
+ for choice in self.section.display_list.choices:
+ visited = choice.dispatch(self.visitor)
+ if predicate(visited):
+ yield visited
diff --git a/nobilis.py b/nobilis.py
--- a/nobilis.py
+++ b/nobilis.py
@@ -146,6 +146,130 @@
()),
+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)),
+
+
MIN_SELECTED = 'min_selected'
MAX_SELECTED = 'max_selected'
SELECTED = 'selected'
Comments