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) + + +@StateVisitor.bind(Choice) +class StateVisitor(Caching): + + is_valid = True + + selected = False + + @property + def choice(self): + return self.node + + +@StateVisitor.bind(ChoiceDisplay) +class StateVisitor(ChoiceButton): + + enabled = False + + selected = False + + @property + def choice_display(self): + return self.node + + +@StateVisitor.bind(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) + + +@StateVisitor.bind(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 + + +@StateVisitor.bind(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 + + +@StateVisitor.bind(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) + + +@StateVisitor.bind(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'