exploration.core

  • Authors: Peter Mawhorter
  • Consulted:
  • Date: 2022-3-3
  • Purpose: Core types and tools for dealing with them.

This file defines the main types used for processing and storing DiscreteExploration objects. Note that the types in open.py like OpenExploration represent more generic and broadly capable types.

Key types defined here are:

  • DecisionGraph: Represents a graph of decisions, including observed connections to unknown destinations. This works well for games that focus on rooms with discrete exits where there are few open spaces to explore, and where pretending that each decision has a discrete set of options is not too much of a distortion.
  • DiscreteExploration: A list of DecisionGraphs with position and transition information representing exploration over time.
    1"""
    2- Authors: Peter Mawhorter
    3- Consulted:
    4- Date: 2022-3-3
    5- Purpose: Core types and tools for dealing with them.
    6
    7This file defines the main types used for processing and storing
    8`DiscreteExploration` objects. Note that the types in `open.py` like
    9`OpenExploration` represent more generic and broadly capable types.
   10
   11Key types defined here are:
   12
   13- `DecisionGraph`: Represents a graph of decisions, including observed
   14    connections to unknown destinations. This works well for games that
   15    focus on rooms with discrete exits where there are few open spaces to
   16    explore, and where pretending that each decision has a discrete set
   17    of options is not too much of a distortion.
   18- `DiscreteExploration`: A list of `DecisionGraph`s with position and
   19    transition information representing exploration over time.
   20"""
   21
   22# TODO: Some way to specify the visibility conditions of a transition,
   23# separately from its traversal conditions? Or at least a way to specify
   24# that a transition is only visible when traversable (like successive
   25# upgrades or warp zone connections).
   26
   27from typing import (
   28    Any, Optional, List, Set, Union, cast, Tuple, Dict, TypedDict,
   29    Sequence, Collection, Literal, get_args, Callable, TypeVar,
   30    Iterator, Generator
   31)
   32
   33import copy
   34import warnings
   35import inspect
   36
   37from . import graphs
   38from . import base
   39from . import utils
   40from . import commands
   41
   42
   43#---------#
   44# Globals #
   45#---------#
   46
   47ENDINGS_DOMAIN = 'endings'
   48"""
   49Domain value for endings.
   50"""
   51
   52TRIGGERS_DOMAIN = 'triggers'
   53"""
   54Domain value for triggers.
   55"""
   56
   57
   58#------------------#
   59# Supporting types #
   60#------------------#
   61
   62
   63LookupResult = TypeVar('LookupResult')
   64"""
   65A type variable for lookup results from the generic
   66`DecisionGraph.localLookup` function.
   67"""
   68
   69LookupLayersList = List[Union[None, int, str]]
   70"""
   71A list of layers to look things up in, consisting of `None` for the
   72starting provided decision set, integers for zone heights, and some
   73custom strings like "fallback" and "all" for fallback sets.
   74"""
   75
   76
   77class DecisionInfo(TypedDict):
   78    """
   79    The information stored per-decision in a `DecisionGraph` includes
   80    the decision name (since the key is a decision ID), the domain, a
   81    tags dictionary, and an annotations list.
   82    """
   83    name: base.DecisionName
   84    domain: base.Domain
   85    tags: Dict[base.Tag, base.TagValue]
   86    annotations: List[base.Annotation]
   87
   88
   89#-----------------------#
   90# Transition properties #
   91#-----------------------#
   92
   93class TransitionProperties(TypedDict, total=False):
   94    """
   95    Represents bundled properties of a transition, including a
   96    requirement, effects, tags, and/or annotations. Does not include the
   97    reciprocal. Has the following slots:
   98
   99    - `'requirement'`: The requirement for the transition. This is
  100        always a `Requirement`, although it might be `ReqNothing` if
  101        nothing special is required.
  102    - `'consequence'`: The `Consequence` of the transition.
  103    - `'tags'`: Any tags applied to the transition (as a dictionary).
  104    - `'annotations'`: A list of annotations applied to the transition.
  105    """
  106    requirement: base.Requirement
  107    consequence: base.Consequence
  108    tags: Dict[base.Tag, base.TagValue]
  109    annotations: List[base.Annotation]
  110
  111
  112def mergeProperties(
  113    a: Optional[TransitionProperties],
  114    b: Optional[TransitionProperties]
  115) -> TransitionProperties:
  116    """
  117    Merges two sets of transition properties, following these rules:
  118
  119    1. Tags and annotations are combined. Annotations from the
  120        second property set are ordered after those from the first.
  121    2. If one of the transitions has a `ReqNothing` instance as its
  122        requirement, we use the other requirement. If both have
  123        complex requirements, we create a new `ReqAll` which
  124        combines them as the requirement.
  125    3. The consequences are merged by placing all of the consequences of
  126        the first transition before those of the second one. This may in
  127        some cases change the net outcome of those consequences,
  128        because not all transition properties are compatible. (Imagine
  129        merging two transitions one of which causes a capability to be
  130        gained and the other of which causes a capability to be lost.
  131        What should happen?).
  132    4. The result will not list a reciprocal.
  133
  134    If either transition is `None`, then a deep copy of the other is
  135    returned. If both are `None`, then an empty transition properties
  136    dictionary is returned, with `ReqNothing` as the requirement, no
  137    effects, no tags, and no annotations.
  138
  139    Deep copies of consequences are always made, so that any `Effects`
  140    applications which edit effects won't end up with entangled effects.
  141    """
  142    if a is None:
  143        if b is None:
  144            return {
  145                "requirement": base.ReqNothing(),
  146                "consequence": [],
  147                "tags": {},
  148                "annotations": []
  149            }
  150        else:
  151            return copy.deepcopy(b)
  152    elif b is None:
  153        return copy.deepcopy(a)
  154    # implicitly neither a or b is None below
  155
  156    result: TransitionProperties = {
  157        "requirement": base.ReqNothing(),
  158        "consequence": copy.deepcopy(a["consequence"] + b["consequence"]),
  159        "tags": a["tags"] | b["tags"],
  160        "annotations": a["annotations"] + b["annotations"]
  161    }
  162
  163    if a["requirement"] == base.ReqNothing():
  164        result["requirement"] = b["requirement"]
  165    elif b["requirement"] == base.ReqNothing():
  166        result["requirement"] = a["requirement"]
  167    else:
  168        result["requirement"] = base.ReqAll(
  169            [a["requirement"], b["requirement"]]
  170        )
  171
  172    return result
  173
  174
  175#---------------------#
  176# Errors and warnings #
  177#---------------------#
  178
  179class TransitionBlockedWarning(Warning):
  180    """
  181    An warning type for indicating that a transition which has been
  182    requested does not have its requirements satisfied by the current
  183    game state.
  184    """
  185    pass
  186
  187
  188class BadStart(ValueError):
  189    """
  190    An error raised when the start method is used improperly.
  191    """
  192    pass
  193
  194
  195class MissingDecisionError(KeyError):
  196    """
  197    An error raised when attempting to use a decision that does not
  198    exist.
  199    """
  200    pass
  201
  202
  203class AmbiguousDecisionSpecifierError(KeyError):
  204    """
  205    An error raised when an ambiguous decision specifier is provided.
  206    Note that if a decision specifier simply doesn't match anything, you
  207    will get a `MissingDecisionError` instead.
  208    """
  209    pass
  210
  211
  212class AmbiguousTransitionError(KeyError):
  213    """
  214    An error raised when an ambiguous transition is specified.
  215    If a transition specifier simply doesn't match anything, you
  216    will get a `MissingTransitionError` instead.
  217    """
  218    pass
  219
  220
  221class MissingTransitionError(KeyError):
  222    """
  223    An error raised when attempting to use a transition that does not
  224    exist.
  225    """
  226    pass
  227
  228
  229class MissingMechanismError(KeyError):
  230    """
  231    An error raised when attempting to use a mechanism that does not
  232    exist.
  233    """
  234    pass
  235
  236
  237class MissingZoneError(KeyError):
  238    """
  239    An error raised when attempting to use a zone that does not exist.
  240    """
  241    pass
  242
  243
  244class InvalidLevelError(ValueError):
  245    """
  246    An error raised when an operation fails because of an invalid zone
  247    level.
  248    """
  249    pass
  250
  251
  252class InvalidDestinationError(ValueError):
  253    """
  254    An error raised when attempting to perform an operation with a
  255    transition but that transition does not lead to a destination that's
  256    compatible with the operation.
  257    """
  258    pass
  259
  260
  261class ExplorationStatusError(ValueError):
  262    """
  263    An error raised when attempting to perform an operation that
  264    requires a previously-visited destination with a decision that
  265    represents a not-yet-visited decision, or vice versa. For
  266    `Situation`s, Exploration states 'unknown', 'hypothesized', and
  267    'noticed' count as "not-yet-visited" while 'exploring' and 'explored'
  268    count as "visited" (see `base.hasBeenVisited`) Meanwhile, in a
  269    `DecisionGraph` where exploration statuses are not present, the
  270    presence or absence of the 'unconfirmed' tag is used to determine
  271    whether something has been confirmed or not.
  272    """
  273    pass
  274
  275
  276WARN_OF_NAME_COLLISIONS = False
  277"""
  278Whether or not to issue warnings when two decision names are the same.
  279"""
  280
  281
  282class DecisionCollisionWarning(Warning):
  283    """
  284    A warning raised when attempting to create a new decision using the
  285    name of a decision that already exists.
  286    """
  287    pass
  288
  289
  290class TransitionCollisionError(ValueError):
  291    """
  292    An error raised when attempting to re-use a transition name for a
  293    new transition, or otherwise when a transition name conflicts with
  294    an already-established transition.
  295    """
  296    pass
  297
  298
  299class MechanismCollisionError(ValueError):
  300    """
  301    An error raised when attempting to re-use a mechanism name at the
  302    same decision where a mechanism with that name already exists.
  303    """
  304    pass
  305
  306
  307class DomainCollisionError(KeyError):
  308    """
  309    An error raised when attempting to create a domain with the same
  310    name as an existing domain.
  311    """
  312    pass
  313
  314
  315class MissingFocalContextError(KeyError):
  316    """
  317    An error raised when attempting to pick out a focal context with a
  318    name that doesn't exist.
  319    """
  320    pass
  321
  322
  323class FocalContextCollisionError(KeyError):
  324    """
  325    An error raised when attempting to create a focal context with the
  326    same name as an existing focal context.
  327    """
  328    pass
  329
  330
  331class InvalidActionError(TypeError):
  332    """
  333    An error raised when attempting to take an exploration action which
  334    is not correctly formed.
  335    """
  336    pass
  337
  338
  339class ImpossibleActionError(ValueError):
  340    """
  341    An error raised when attempting to take an exploration action which
  342    is correctly formed but which specifies an action that doesn't match
  343    up with the graph state.
  344    """
  345    pass
  346
  347
  348class DoubleActionError(ValueError):
  349    """
  350    An error raised when attempting to set up an `ExplorationAction`
  351    when the current situation already has an action specified.
  352    """
  353    pass
  354
  355
  356class InactiveDomainWarning(Warning):
  357    """
  358    A warning used when an inactive domain is referenced but the
  359    operation in progress can still succeed (for example when
  360    deactivating an already-inactive domain).
  361    """
  362
  363
  364class ZoneCollisionError(ValueError):
  365    """
  366    An error raised when attempting to re-use a zone name for a new zone,
  367    or otherwise when a zone name conflicts with an already-established
  368    zone.
  369    """
  370    pass
  371
  372
  373#---------------------#
  374# DecisionGraph class #
  375#---------------------#
  376
  377class DecisionGraph(
  378    graphs.UniqueExitsGraph[base.DecisionID, base.Transition]
  379):
  380    """
  381    Represents a view of the world as a topological graph at a moment in
  382    time. It derives from `networkx.MultiDiGraph`.
  383
  384    Each node (a `Decision`) represents a place in the world where there
  385    are multiple opportunities for travel/action, or a dead end where
  386    you must turn around and go back; typically this is a single room in
  387    a game, but sometimes one room has multiple decision points. Edges
  388    (`Transition`s) represent choices that can be made to travel to
  389    other decision points (e.g., taking the left door), or when they are
  390    self-edges, they represent actions that can be taken within a
  391    location that affect the world or the game state.
  392
  393    Each `Transition` includes a `Effects` dictionary
  394    indicating the effects that it has. Other effects of the transition
  395    that are not simple enough to be included in this format may be
  396    represented in an `DiscreteExploration` by changing the graph in the
  397    next step to reflect further effects of a transition.
  398
  399    In addition to normal transitions between decisions, a
  400    `DecisionGraph` can represent potential transitions which lead to
  401    unknown destinations. These are represented by adding decisions with
  402    the `'unconfirmed'` tag (whose names where not specified begin with
  403    `'_u.'`) with a separate unconfirmed decision for each transition
  404    (although where it's known that two transitions lead to the same
  405    unconfirmed decision, this can be represented as well).
  406
  407    Both nodes and edges can have `Annotation`s associated with them that
  408    include extra details about the explorer's perception of the
  409    situation. They can also have `Tag`s, which represent specific
  410    categories a transition or decision falls into.
  411
  412    Nodes can also be part of one or more `Zones`, and zones can also be
  413    part of other zones, allowing for a hierarchical description of the
  414    underlying space.
  415
  416    Equivalences can be specified to mark that some combination of
  417    capabilities can stand in for another capability.
  418    """
  419    def __init__(self) -> None:
  420        super().__init__()
  421
  422        self.zones: Dict[base.Zone, base.ZoneInfo] = {}
  423        """
  424        Mapping from zone names to zone info
  425        """
  426
  427        self.unknownCount: int = 0
  428        """
  429        Number of unknown decisions that have been created (not number
  430        of current unknown decisions, which is likely lower)
  431        """
  432
  433        self.equivalences: base.Equivalences = {}
  434        """
  435        See `base.Equivalences`. Determines what capabilities and/or
  436        mechanism states can count as active based on alternate
  437        requirements.
  438        """
  439
  440        self.reversionTypes: Dict[str, Set[str]] = {}
  441        """
  442        This tracks shorthand reversion types. See `base.revertedState`
  443        for how these are applied. Keys are custom names and values are
  444        reversion type strings that `base.revertedState` could access.
  445        """
  446
  447        self.nextID: base.DecisionID = 0
  448        """
  449        The ID to use for the next new decision we create.
  450        """
  451
  452        self.nextMechanismID: base.MechanismID = 0
  453        """
  454        ID for the next mechanism.
  455        """
  456
  457        self.mechanisms: Dict[
  458            base.MechanismID,
  459            Tuple[Optional[base.DecisionID], base.MechanismName]
  460        ] = {}
  461        """
  462        Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`)
  463        pairs. For global mechanisms, the `DecisionID` is None.
  464        """
  465
  466        self.globalMechanisms: Dict[
  467            base.MechanismName,
  468            base.MechanismID
  469        ] = {}
  470        """
  471        Global mechanisms
  472        """
  473
  474        self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {}
  475        """
  476        A cache for name -> ID lookups
  477        """
  478
  479    # Note: not hashable
  480
  481    def __eq__(self, other):
  482        """
  483        Equality checker. `DecisionGraph`s can only be equal to other
  484        `DecisionGraph`s, not to other kinds of things.
  485        """
  486        if not isinstance(other, DecisionGraph):
  487            return False
  488        else:
  489            # Checks nodes, edges, and all attached data
  490            if not super().__eq__(other):
  491                return False
  492
  493            # Check unknown count
  494            if self.unknownCount != other.unknownCount:
  495                return False
  496
  497            # Check zones
  498            if self.zones != other.zones:
  499                return False
  500
  501            # Check equivalences
  502            if self.equivalences != other.equivalences:
  503                return False
  504
  505            # Check reversion types
  506            if self.reversionTypes != other.reversionTypes:
  507                return False
  508
  509            # Check mechanisms
  510            if self.nextMechanismID != other.nextMechanismID:
  511                return False
  512
  513            if self.mechanisms != other.mechanisms:
  514                return False
  515
  516            if self.globalMechanisms != other.globalMechanisms:
  517                return False
  518
  519            # Check names:
  520            if self.nameLookup != other.nameLookup:
  521                return False
  522
  523            return True
  524
  525    def listDifferences(
  526        self,
  527        other: 'DecisionGraph'
  528    ) -> Generator[str, None, None]:
  529        """
  530        Generates strings describing differences between this graph and
  531        another graph. This does NOT perform graph matching, so it will
  532        consider graphs different even if they have identical structures
  533        but use different IDs for the nodes in those structures.
  534        """
  535        if not isinstance(other, DecisionGraph):
  536            yield "other is not a graph"
  537        else:
  538            suppress = False
  539            myNodes = set(self.nodes)
  540            theirNodes = set(other.nodes)
  541            for n in myNodes:
  542                if n not in theirNodes:
  543                    suppress = True
  544                    yield (
  545                        f"other graph missing node {n}"
  546                    )
  547                else:
  548                    if self.nodes[n] != other.nodes[n]:
  549                        suppress = True
  550                        yield (
  551                            f"other graph has differences at node {n}:"
  552                            f"\n  Ours:  {self.nodes[n]}"
  553                            f"\nTheirs:  {other.nodes[n]}"
  554                        )
  555                    myDests = self.destinationsFrom(n)
  556                    theirDests = other.destinationsFrom(n)
  557                    for tr in myDests:
  558                        myTo = myDests[tr]
  559                        if tr not in theirDests:
  560                            suppress = True
  561                            yield (
  562                                f"at {self.identityOf(n)}: other graph"
  563                                f" missing transition {tr!r}"
  564                            )
  565                        else:
  566                            theirTo = theirDests[tr]
  567                            if myTo != theirTo:
  568                                suppress = True
  569                                yield (
  570                                    f"at {self.identityOf(n)}: other"
  571                                    f" graph transition {tr!r} leads to"
  572                                    f" {theirTo} instead of {myTo}"
  573                                )
  574                            else:
  575                                myProps = self.edges[n, myTo, tr]  # type:ignore [index] # noqa
  576                                theirProps = other.edges[n, myTo, tr]  # type:ignore [index] # noqa
  577                                if myProps != theirProps:
  578                                    suppress = True
  579                                    yield (
  580                                        f"at {self.identityOf(n)}: other"
  581                                        f" graph transition {tr!r} has"
  582                                        f" different properties:"
  583                                        f"\n  Ours:  {myProps}"
  584                                        f"\nTheirs:  {theirProps}"
  585                                    )
  586            for extra in theirNodes - myNodes:
  587                suppress = True
  588                yield (
  589                    f"other graph has extra node {extra}"
  590                )
  591
  592            # TODO: Fix networkx stubs!
  593            if self.graph != other.graph:  # type:ignore [attr-defined]
  594                suppress = True
  595                yield (
  596                    " different graph attributes:"  # type:ignore [attr-defined]  # noqa
  597                    f"\n  Ours:  {self.graph}"
  598                    f"\nTheirs:  {other.graph}"
  599                )
  600
  601            # Checks any other graph data we might have missed
  602            if not super().__eq__(other) and not suppress:
  603                for attr in dir(self):
  604                    if attr.startswith('__') and attr.endswith('__'):
  605                        continue
  606                    if not hasattr(other, attr):
  607                        yield f"other graph missing attribute: {attr!r}"
  608                    else:
  609                        myVal = getattr(self, attr)
  610                        theirVal = getattr(other, attr)
  611                        if (
  612                            myVal != theirVal
  613                        and not ((callable(myVal) and callable(theirVal)))
  614                        ):
  615                            yield (
  616                                f"other has different val for {attr!r}:"
  617                                f"\n  Ours:  {myVal}"
  618                                f"\nTheirs:  {theirVal}"
  619                            )
  620                for attr in sorted(set(dir(other)) - set(dir(self))):
  621                    yield f"other has extra attribute: {attr!r}"
  622                yield "graph data is different"
  623                # TODO: More detail here!
  624
  625            # Check unknown count
  626            if self.unknownCount != other.unknownCount:
  627                yield "unknown count is different"
  628
  629            # Check zones
  630            if self.zones != other.zones:
  631                yield "zones are different"
  632
  633            # Check equivalences
  634            if self.equivalences != other.equivalences:
  635                yield "equivalences are different"
  636
  637            # Check reversion types
  638            if self.reversionTypes != other.reversionTypes:
  639                yield "reversionTypes are different"
  640
  641            # Check mechanisms
  642            if self.nextMechanismID != other.nextMechanismID:
  643                yield "nextMechanismID is different"
  644
  645            if self.mechanisms != other.mechanisms:
  646                yield "mechanisms are different"
  647
  648            if self.globalMechanisms != other.globalMechanisms:
  649                yield "global mechanisms are different"
  650
  651            # Check names:
  652            if self.nameLookup != other.nameLookup:
  653                for name in self.nameLookup:
  654                    if name not in other.nameLookup:
  655                        yield (
  656                            f"other graph is missing name lookup entry"
  657                            f" for {name!r}"
  658                        )
  659                    else:
  660                        mine = self.nameLookup[name]
  661                        theirs = other.nameLookup[name]
  662                        if theirs != mine:
  663                            yield (
  664                                f"name lookup for {name!r} is {theirs}"
  665                                f" instead of {mine}"
  666                            )
  667                extras = set(other.nameLookup) - set(self.nameLookup)
  668                if extras:
  669                    yield (
  670                        f"other graph has extra name lookup entries:"
  671                        f" {extras}"
  672                    )
  673
  674    def _assignID(self) -> base.DecisionID:
  675        """
  676        Returns the next `base.DecisionID` to use and increments the
  677        next ID counter.
  678        """
  679        result = self.nextID
  680        self.nextID += 1
  681        return result
  682
  683    def _assignMechanismID(self) -> base.MechanismID:
  684        """
  685        Returns the next `base.MechanismID` to use and increments the
  686        next ID counter.
  687        """
  688        result = self.nextMechanismID
  689        self.nextMechanismID += 1
  690        return result
  691
  692    def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
  693        """
  694        Retrieves the decision info for the specified decision, as a
  695        live editable dictionary.
  696
  697        For example:
  698
  699        >>> g = DecisionGraph()
  700        >>> g.addDecision('A')
  701        0
  702        >>> g.annotateDecision('A', 'note')
  703        >>> g.decisionInfo(0)
  704        {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
  705        """
  706        return cast(DecisionInfo, self.nodes[dID])
  707
  708    def resolveDecision(
  709        self,
  710        spec: base.AnyDecisionSpecifier,
  711        zoneHint: Optional[base.Zone] = None,
  712        domainHint: Optional[base.Domain] = None
  713    ) -> base.DecisionID:
  714        """
  715        Given a decision specifier returns the ID associated with that
  716        decision, or raises an `AmbiguousDecisionSpecifierError` or a
  717        `MissingDecisionError` if the specified decision is either
  718        missing or ambiguous. Cannot handle strings that contain domain
  719        and/or zone parts; use
  720        `parsing.ParseFormat.parseDecisionSpecifier` to turn such
  721        strings into `DecisionSpecifier`s if you need to first.
  722
  723        Examples:
  724
  725        >>> g = DecisionGraph()
  726        >>> g.addDecision('A')
  727        0
  728        >>> g.addDecision('B')
  729        1
  730        >>> g.addDecision('C')
  731        2
  732        >>> g.addDecision('A')
  733        3
  734        >>> g.addDecision('B', 'menu')
  735        4
  736        >>> g.createZone('Z', 0)
  737        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
  738 annotations=[])
  739        >>> g.createZone('Z2', 0)
  740        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
  741 annotations=[])
  742        >>> g.createZone('Zup', 1)
  743        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
  744 annotations=[])
  745        >>> g.addDecisionToZone(0, 'Z')
  746        >>> g.addDecisionToZone(1, 'Z')
  747        >>> g.addDecisionToZone(2, 'Z')
  748        >>> g.addDecisionToZone(3, 'Z2')
  749        >>> g.addZoneToZone('Z', 'Zup')
  750        >>> g.addZoneToZone('Z2', 'Zup')
  751        >>> g.resolveDecision(1)
  752        1
  753        >>> g.resolveDecision('A')
  754        Traceback (most recent call last):
  755        ...
  756        exploration.core.AmbiguousDecisionSpecifierError...
  757        >>> g.resolveDecision('B')
  758        Traceback (most recent call last):
  759        ...
  760        exploration.core.AmbiguousDecisionSpecifierError...
  761        >>> g.resolveDecision('C')
  762        2
  763        >>> g.resolveDecision('A', 'Z')
  764        0
  765        >>> g.resolveDecision('A', zoneHint='Z2')
  766        3
  767        >>> g.resolveDecision('B', domainHint='main')
  768        1
  769        >>> g.resolveDecision('B', None, 'menu')
  770        4
  771        >>> g.resolveDecision('B', zoneHint='Z2')
  772        Traceback (most recent call last):
  773        ...
  774        exploration.core.MissingDecisionError...
  775        >>> g.resolveDecision('A', domainHint='menu')
  776        Traceback (most recent call last):
  777        ...
  778        exploration.core.MissingDecisionError...
  779        >>> g.resolveDecision('A', domainHint='madeup')
  780        Traceback (most recent call last):
  781        ...
  782        exploration.core.MissingDecisionError...
  783        >>> g.resolveDecision('A', zoneHint='madeup')
  784        Traceback (most recent call last):
  785        ...
  786        exploration.core.MissingDecisionError...
  787        """
  788        # Parse it to either an ID or specifier if it's a string:
  789        if isinstance(spec, str):
  790            try:
  791                spec = int(spec)
  792            except ValueError:
  793                pass
  794
  795        # If it's an ID, check for existence:
  796        if isinstance(spec, base.DecisionID):
  797            if spec in self:
  798                return spec
  799            else:
  800                raise MissingDecisionError(
  801                    f"There is no decision with ID {spec!r}."
  802                )
  803        else:
  804            if isinstance(spec, base.DecisionName):
  805                spec = base.DecisionSpecifier(
  806                    domain=None,
  807                    zone=None,
  808                    name=spec
  809                )
  810            elif not isinstance(spec, base.DecisionSpecifier):
  811                raise TypeError(
  812                    f"Specification is not provided as a"
  813                    f" DecisionSpecifier or other valid type. (got type"
  814                    f" {type(spec)})."
  815                )
  816
  817            # Merge domain hints from spec/args
  818            if (
  819                spec.domain is not None
  820            and domainHint is not None
  821            and spec.domain != domainHint
  822            ):
  823                raise ValueError(
  824                    f"Specifier {repr(spec)} includes domain hint"
  825                    f" {repr(spec.domain)} which is incompatible with"
  826                    f" explicit domain hint {repr(domainHint)}."
  827                )
  828            else:
  829                domainHint = spec.domain or domainHint
  830
  831            # Merge zone hints from spec/args
  832            if (
  833                spec.zone is not None
  834            and zoneHint is not None
  835            and spec.zone != zoneHint
  836            ):
  837                raise ValueError(
  838                    f"Specifier {repr(spec)} includes zone hint"
  839                    f" {repr(spec.zone)} which is incompatible with"
  840                    f" explicit zone hint {repr(zoneHint)}."
  841                )
  842            else:
  843                zoneHint = spec.zone or zoneHint
  844
  845            if spec.name not in self.nameLookup:
  846                raise MissingDecisionError(
  847                    f"No decision named {repr(spec.name)}."
  848                )
  849            else:
  850                options = self.nameLookup[spec.name]
  851                if len(options) == 0:
  852                    raise MissingDecisionError(
  853                        f"No decision named {repr(spec.name)}."
  854                    )
  855                filtered = [
  856                    opt
  857                    for opt in options
  858                    if (
  859                        domainHint is None
  860                     or self.domainFor(opt) == domainHint
  861                    ) and (
  862                        zoneHint is None
  863                     or zoneHint in self.zoneAncestors(opt)
  864                    )
  865                ]
  866                if len(filtered) == 1:
  867                    return filtered[0]
  868                else:
  869                    filterDesc = ""
  870                    if domainHint is not None:
  871                        filterDesc += f" in domain {repr(domainHint)}"
  872                    if zoneHint is not None:
  873                        filterDesc += f" in zone {repr(zoneHint)}"
  874                    if len(filtered) == 0:
  875                        raise MissingDecisionError(
  876                            f"No decisions named"
  877                            f" {repr(spec.name)}{filterDesc}."
  878                        )
  879                    else:
  880                        raise AmbiguousDecisionSpecifierError(
  881                            f"There are {len(filtered)} decisions"
  882                            f" named {repr(spec.name)}{filterDesc}."
  883                        )
  884
  885    def getDecision(
  886        self,
  887        decision: base.AnyDecisionSpecifier,
  888        zoneHint: Optional[base.Zone] = None,
  889        domainHint: Optional[base.Domain] = None
  890    ) -> Optional[base.DecisionID]:
  891        """
  892        Works like `resolveDecision` but returns None instead of raising
  893        a `MissingDecisionError` if the specified decision isn't listed.
  894        May still raise an `AmbiguousDecisionSpecifierError`.
  895        """
  896        try:
  897            return self.resolveDecision(
  898                decision,
  899                zoneHint,
  900                domainHint
  901            )
  902        except MissingDecisionError:
  903            return None
  904
  905    def nameFor(
  906        self,
  907        decision: base.AnyDecisionSpecifier
  908    ) -> base.DecisionName:
  909        """
  910        Returns the name of the specified decision. Note that names are
  911        not necessarily unique.
  912
  913        Example:
  914
  915        >>> d = DecisionGraph()
  916        >>> d.addDecision('A')
  917        0
  918        >>> d.addDecision('B')
  919        1
  920        >>> d.addDecision('B')
  921        2
  922        >>> d.nameFor(0)
  923        'A'
  924        >>> d.nameFor(1)
  925        'B'
  926        >>> d.nameFor(2)
  927        'B'
  928        >>> d.nameFor(3)
  929        Traceback (most recent call last):
  930        ...
  931        exploration.core.MissingDecisionError...
  932        """
  933        dID = self.resolveDecision(decision)
  934        return self.nodes[dID]['name']
  935
  936    def shortIdentity(
  937        self,
  938        decision: Optional[base.AnyDecisionSpecifier],
  939        includeZones: bool = True,
  940        alwaysDomain: Optional[bool] = None
  941    ):
  942        """
  943        Returns a string containing the name for the given decision,
  944        prefixed by its level-0 zone(s) and domain. If the value provided
  945        is `None`, it returns the string "(nowhere)".
  946
  947        If `includeZones` is true (the default) then zone information
  948        is included before the decision name.
  949
  950        If `alwaysDomain` is true or false, then the domain information
  951        will always (or never) be included. If it's `None` (the default)
  952        then domain info will only be included for decisions which are
  953        not in the default domain.
  954        """
  955        if decision is None:
  956            return "(nowhere)"
  957        else:
  958            dID = self.resolveDecision(decision)
  959            thisDomain = self.domainFor(dID)
  960            dSpec = ''
  961            zSpec = ''
  962            if (
  963                alwaysDomain is True
  964             or (
  965                    alwaysDomain is None
  966                and thisDomain != base.DEFAULT_DOMAIN
  967                )
  968            ):
  969                dSpec = thisDomain + '//'  # TODO: Don't hardcode this?
  970            if includeZones:
  971                zones = [
  972                    z
  973                    for z in self.zoneParents(dID)
  974                    if self.zones[z].level == 0
  975                ]
  976                if len(zones) == 1:
  977                    zSpec = zones[0] + '::'  # TODO: Don't hardcode this?
  978                elif len(zones) > 1:
  979                    zSpec = '[' + ', '.join(sorted(zones)) + ']::'
  980                # else leave zSpec empty
  981
  982            return f"{dSpec}{zSpec}{self.nameFor(dID)}"
  983
  984    def identityOf(
  985        self,
  986        decision: Optional[base.AnyDecisionSpecifier],
  987        includeZones: bool = True,
  988        alwaysDomain: Optional[bool] = None
  989    ) -> str:
  990        """
  991        Returns the given node's ID, plus its `shortIdentity` in
  992        parentheses. Arguments are passed through to `shortIdentity`.
  993        """
  994        if decision is None:
  995            return "(nowhere)"
  996        else:
  997            dID = self.resolveDecision(decision)
  998            short = self.shortIdentity(decision, includeZones, alwaysDomain)
  999            return f"{dID} ({short})"
 1000
 1001    def namesListing(
 1002        self,
 1003        decisions: Collection[base.DecisionID],
 1004        includeZones: bool = True,
 1005        indent: int = 2
 1006    ) -> str:
 1007        """
 1008        Returns a multi-line string containing an indented listing of
 1009        the provided decision IDs with their names in parentheses after
 1010        each. Useful for debugging & error messages.
 1011
 1012        Includes level-0 zones where applicable, with a zone separator
 1013        before the decision, unless `includeZones` is set to False. Where
 1014        there are multiple level-0 zones, they're listed together in
 1015        brackets.
 1016
 1017        Uses the string '(none)' when there are no decisions are in the
 1018        list.
 1019
 1020        Set `indent` to something other than 2 to control how much
 1021        indentation is added.
 1022
 1023        For example:
 1024
 1025        >>> g = DecisionGraph()
 1026        >>> g.addDecision('A')
 1027        0
 1028        >>> g.addDecision('B')
 1029        1
 1030        >>> g.addDecision('C')
 1031        2
 1032        >>> g.namesListing(['A', 'C', 'B'])
 1033        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
 1034        >>> g.namesListing([])
 1035        '  (none)\\n'
 1036        >>> g.createZone('zone', 0)
 1037        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1038 annotations=[])
 1039        >>> g.createZone('zone2', 0)
 1040        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1041 annotations=[])
 1042        >>> g.createZone('zoneUp', 1)
 1043        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 1044 annotations=[])
 1045        >>> g.addDecisionToZone(0, 'zone')
 1046        >>> g.addDecisionToZone(1, 'zone')
 1047        >>> g.addDecisionToZone(1, 'zone2')
 1048        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
 1049        >>> g.namesListing(['A', 'C', 'B'])
 1050        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
 1051        """
 1052        ind = ' ' * indent
 1053        if len(decisions) == 0:
 1054            return ind + '(none)\n'
 1055        else:
 1056            result = ''
 1057            for dID in decisions:
 1058                result += ind + self.identityOf(dID, includeZones) + '\n'
 1059            return result
 1060
 1061    def destinationsListing(
 1062        self,
 1063        destinations: Dict[base.Transition, base.DecisionID],
 1064        includeZones: bool = True,
 1065        indent: int = 2
 1066    ) -> str:
 1067        """
 1068        Returns a multi-line string containing an indented listing of
 1069        the provided transitions along with their destinations and the
 1070        names of those destinations in parentheses. Useful for debugging
 1071        & error messages. (Use e.g., `destinationsFrom` to get a
 1072        transitions -> destinations dictionary in the required format.)
 1073
 1074        Uses the string '(no transitions)' when there are no transitions
 1075        in the dictionary.
 1076
 1077        Set `indent` to something other than 2 to control how much
 1078        indentation is added.
 1079
 1080        For example:
 1081
 1082        >>> g = DecisionGraph()
 1083        >>> g.addDecision('A')
 1084        0
 1085        >>> g.addDecision('B')
 1086        1
 1087        >>> g.addDecision('C')
 1088        2
 1089        >>> g.addTransition('A', 'north', 'B', 'south')
 1090        >>> g.addTransition('B', 'east', 'C', 'west')
 1091        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
 1092        >>> g.destinationsListing(g.destinationsFrom('A'))
 1093        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
 1094        >>> g.destinationsListing(g.destinationsFrom('B'))
 1095        '  south to 0 (A)\\n  east to 2 (C)\\n'
 1096        >>> g.destinationsListing({})
 1097        '  (none)\\n'
 1098        >>> g.createZone('zone', 0)
 1099        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1100 annotations=[])
 1101        >>> g.addDecisionToZone(0, 'zone')
 1102        >>> g.destinationsListing(g.destinationsFrom('B'))
 1103        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
 1104        """
 1105        ind = ' ' * indent
 1106        if len(destinations) == 0:
 1107            return ind + '(none)\n'
 1108        else:
 1109            result = ''
 1110            for transition, dID in destinations.items():
 1111                line = f"{transition} to {self.identityOf(dID, includeZones)}"
 1112                result += ind + line + '\n'
 1113            return result
 1114
 1115    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
 1116        """
 1117        Returns the domain that a decision belongs to.
 1118        """
 1119        dID = self.resolveDecision(decision)
 1120        return self.nodes[dID]['domain']
 1121
 1122    def allDecisionsInDomain(
 1123        self,
 1124        domain: base.Domain
 1125    ) -> Set[base.DecisionID]:
 1126        """
 1127        Returns the set of all `DecisionID`s for decisions in the
 1128        specified domain.
 1129        """
 1130        return set(dID for dID in self if self.nodes[dID]['domain'] == domain)
 1131
 1132    def destination(
 1133        self,
 1134        decision: base.AnyDecisionSpecifier,
 1135        transition: base.Transition
 1136    ) -> base.DecisionID:
 1137        """
 1138        Overrides base `UniqueExitsGraph.destination` to raise
 1139        `MissingDecisionError` or `MissingTransitionError` as
 1140        appropriate, and to work with an `AnyDecisionSpecifier`.
 1141        """
 1142        dID = self.resolveDecision(decision)
 1143        try:
 1144            return super().destination(dID, transition)
 1145        except KeyError:
 1146            raise MissingTransitionError(
 1147                f"Transition {transition!r} does not exist at decision"
 1148                f" {self.identityOf(dID)}."
 1149            )
 1150
 1151    def getDestination(
 1152        self,
 1153        decision: base.AnyDecisionSpecifier,
 1154        transition: base.Transition,
 1155        default: Any = None
 1156    ) -> Optional[base.DecisionID]:
 1157        """
 1158        Overrides base `UniqueExitsGraph.getDestination` with different
 1159        argument names, since those matter for the edit DSL.
 1160        """
 1161        dID = self.resolveDecision(decision)
 1162        return super().getDestination(dID, transition)
 1163
 1164    def destinationsFrom(
 1165        self,
 1166        decision: base.AnyDecisionSpecifier
 1167    ) -> Dict[base.Transition, base.DecisionID]:
 1168        """
 1169        Override that just changes the type of the exception from a
 1170        `KeyError` to a `MissingDecisionError` when the source does not
 1171        exist.
 1172        """
 1173        dID = self.resolveDecision(decision)
 1174        return super().destinationsFrom(dID)
 1175
 1176    def bothEnds(
 1177        self,
 1178        decision: base.AnyDecisionSpecifier,
 1179        transition: base.Transition
 1180    ) -> Set[base.DecisionID]:
 1181        """
 1182        Returns a set containing the `DecisionID`(s) for both the start
 1183        and end of the specified transition. Raises a
 1184        `MissingDecisionError` or `MissingTransitionError`if the
 1185        specified decision and/or transition do not exist.
 1186
 1187        Note that for actions since the source and destination are the
 1188        same, the set will have only one element.
 1189        """
 1190        dID = self.resolveDecision(decision)
 1191        result = {dID}
 1192        dest = self.destination(dID, transition)
 1193        if dest is not None:
 1194            result.add(dest)
 1195        return result
 1196
 1197    def decisionActions(
 1198        self,
 1199        decision: base.AnyDecisionSpecifier
 1200    ) -> Set[base.Transition]:
 1201        """
 1202        Retrieves the set of self-edges at a decision. Editing the set
 1203        will not affect the graph.
 1204
 1205        Example:
 1206
 1207        >>> g = DecisionGraph()
 1208        >>> g.addDecision('A')
 1209        0
 1210        >>> g.addDecision('B')
 1211        1
 1212        >>> g.addDecision('C')
 1213        2
 1214        >>> g.addAction('A', 'action1')
 1215        >>> g.addAction('A', 'action2')
 1216        >>> g.addAction('B', 'action3')
 1217        >>> sorted(g.decisionActions('A'))
 1218        ['action1', 'action2']
 1219        >>> g.decisionActions('B')
 1220        {'action3'}
 1221        >>> g.decisionActions('C')
 1222        set()
 1223        """
 1224        result = set()
 1225        dID = self.resolveDecision(decision)
 1226        for transition, dest in self.destinationsFrom(dID).items():
 1227            if dest == dID:
 1228                result.add(transition)
 1229        return result
 1230
 1231    def getTransitionProperties(
 1232        self,
 1233        decision: base.AnyDecisionSpecifier,
 1234        transition: base.Transition
 1235    ) -> TransitionProperties:
 1236        """
 1237        Returns a dictionary containing transition properties for the
 1238        specified transition from the specified decision. The properties
 1239        included are:
 1240
 1241        - 'requirement': The requirement for the transition.
 1242        - 'consequence': Any consequence of the transition.
 1243        - 'tags': Any tags applied to the transition.
 1244        - 'annotations': Any annotations on the transition.
 1245
 1246        The reciprocal of the transition is not included.
 1247
 1248        The result is a clone of the stored properties; edits to the
 1249        dictionary will NOT modify the graph.
 1250        """
 1251        dID = self.resolveDecision(decision)
 1252        dest = self.destination(dID, transition)
 1253
 1254        info: TransitionProperties = copy.deepcopy(
 1255            self.edges[dID, dest, transition]  # type:ignore
 1256        )
 1257        return {
 1258            'requirement': info.get('requirement', base.ReqNothing()),
 1259            'consequence': info.get('consequence', []),
 1260            'tags': info.get('tags', {}),
 1261            'annotations': info.get('annotations', [])
 1262        }
 1263
 1264    def setTransitionProperties(
 1265        self,
 1266        decision: base.AnyDecisionSpecifier,
 1267        transition: base.Transition,
 1268        requirement: Optional[base.Requirement] = None,
 1269        consequence: Optional[base.Consequence] = None,
 1270        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 1271        annotations: Optional[List[base.Annotation]] = None
 1272    ) -> None:
 1273        """
 1274        Sets one or more transition properties all at once. Can be used
 1275        to set the requirement, consequence, tags, and/or annotations.
 1276        Old values are overwritten, although if `None`s are provided (or
 1277        arguments are omitted), corresponding properties are not
 1278        updated.
 1279
 1280        To add tags or annotations to existing tags/annotations instead
 1281        of replacing them, use `tagTransition` or `annotateTransition`
 1282        instead.
 1283        """
 1284        dID = self.resolveDecision(decision)
 1285        if requirement is not None:
 1286            self.setTransitionRequirement(dID, transition, requirement)
 1287        if consequence is not None:
 1288            self.setConsequence(dID, transition, consequence)
 1289        if tags is not None:
 1290            dest = self.destination(dID, transition)
 1291            # TODO: Submit pull request to update MultiDiGraph stubs in
 1292            # types-networkx to include OutMultiEdgeView that accepts
 1293            # from/to/key tuples as indices.
 1294            info = cast(
 1295                TransitionProperties,
 1296                self.edges[dID, dest, transition]  # type:ignore
 1297            )
 1298            info['tags'] = tags
 1299        if annotations is not None:
 1300            dest = self.destination(dID, transition)
 1301            info = cast(
 1302                TransitionProperties,
 1303                self.edges[dID, dest, transition]  # type:ignore
 1304            )
 1305            info['annotations'] = annotations
 1306
 1307    def getTransitionRequirement(
 1308        self,
 1309        decision: base.AnyDecisionSpecifier,
 1310        transition: base.Transition
 1311    ) -> base.Requirement:
 1312        """
 1313        Returns the `Requirement` for accessing a specific transition at
 1314        a specific decision. For transitions which don't have
 1315        requirements, returns a `ReqNothing` instance.
 1316        """
 1317        dID = self.resolveDecision(decision)
 1318        dest = self.destination(dID, transition)
 1319
 1320        info = cast(
 1321            TransitionProperties,
 1322            self.edges[dID, dest, transition]  # type:ignore
 1323        )
 1324
 1325        return info.get('requirement', base.ReqNothing())
 1326
 1327    def setTransitionRequirement(
 1328        self,
 1329        decision: base.AnyDecisionSpecifier,
 1330        transition: base.Transition,
 1331        requirement: Optional[base.Requirement]
 1332    ) -> None:
 1333        """
 1334        Sets the `Requirement` for accessing a specific transition at
 1335        a specific decision. Raises a `KeyError` if the decision or
 1336        transition does not exist.
 1337
 1338        Deletes the requirement if `None` is given as the requirement.
 1339
 1340        Use `parsing.ParseFormat.parseRequirement` first if you have a
 1341        requirement in string format.
 1342
 1343        Does not raise an error if deletion is requested for a
 1344        non-existent requirement, and silently overwrites any previous
 1345        requirement.
 1346        """
 1347        dID = self.resolveDecision(decision)
 1348
 1349        dest = self.destination(dID, transition)
 1350
 1351        info = cast(
 1352            TransitionProperties,
 1353            self.edges[dID, dest, transition]  # type:ignore
 1354        )
 1355
 1356        if requirement is None:
 1357            try:
 1358                del info['requirement']
 1359            except KeyError:
 1360                pass
 1361        else:
 1362            if not isinstance(requirement, base.Requirement):
 1363                raise TypeError(
 1364                    f"Invalid requirement type: {type(requirement)}"
 1365                )
 1366
 1367            info['requirement'] = requirement
 1368
 1369    def getConsequence(
 1370        self,
 1371        decision: base.AnyDecisionSpecifier,
 1372        transition: base.Transition
 1373    ) -> base.Consequence:
 1374        """
 1375        Retrieves the consequence of a transition.
 1376
 1377        A `KeyError` is raised if the specified decision/transition
 1378        combination doesn't exist.
 1379        """
 1380        dID = self.resolveDecision(decision)
 1381
 1382        dest = self.destination(dID, transition)
 1383
 1384        info = cast(
 1385            TransitionProperties,
 1386            self.edges[dID, dest, transition]  # type:ignore
 1387        )
 1388
 1389        return info.get('consequence', [])
 1390
 1391    def addConsequence(
 1392        self,
 1393        decision: base.AnyDecisionSpecifier,
 1394        transition: base.Transition,
 1395        consequence: base.Consequence
 1396    ) -> Tuple[int, int]:
 1397        """
 1398        Adds the given `Consequence` to the consequence list for the
 1399        specified transition, extending that list at the end. Note that
 1400        this does NOT make a copy of the consequence, so it should not
 1401        be used to copy consequences from one transition to another
 1402        without making a deep copy first.
 1403
 1404        A `MissingDecisionError` or a `MissingTransitionError` is raised
 1405        if the specified decision/transition combination doesn't exist.
 1406
 1407        Returns a pair of integers indicating the minimum and maximum
 1408        depth-first-traversal-indices of the added consequence part(s).
 1409        The outer consequence list itself (index 0) is not counted.
 1410
 1411        >>> d = DecisionGraph()
 1412        >>> d.addDecision('A')
 1413        0
 1414        >>> d.addDecision('B')
 1415        1
 1416        >>> d.addTransition('A', 'fwd', 'B', 'rev')
 1417        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
 1418        (1, 1)
 1419        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
 1420        (1, 1)
 1421        >>> ef = d.getConsequence('A', 'fwd')
 1422        >>> er = d.getConsequence('B', 'rev')
 1423        >>> ef == [base.effect(gain='sword')]
 1424        True
 1425        >>> er == [base.effect(lose='sword')]
 1426        True
 1427        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
 1428        (2, 2)
 1429        >>> ef = d.getConsequence('A', 'fwd')
 1430        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
 1431        True
 1432        >>> d.addConsequence(
 1433        ...     'A',
 1434        ...     'fwd',  # adding to consequence with 3 parts already
 1435        ...     [  # outer list not counted because it merges
 1436        ...         base.challenge(  # 1 part
 1437        ...             None,
 1438        ...             0,
 1439        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
 1440        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
 1441        ...         )
 1442        ...     ]
 1443        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
 1444        (3, 7)
 1445        """
 1446        dID = self.resolveDecision(decision)
 1447
 1448        dest = self.destination(dID, transition)
 1449
 1450        info = cast(
 1451            TransitionProperties,
 1452            self.edges[dID, dest, transition]  # type:ignore
 1453        )
 1454
 1455        existing = info.setdefault('consequence', [])
 1456        startIndex = base.countParts(existing)
 1457        existing.extend(consequence)
 1458        endIndex = base.countParts(existing) - 1
 1459        return (startIndex, endIndex)
 1460
 1461    def setConsequence(
 1462        self,
 1463        decision: base.AnyDecisionSpecifier,
 1464        transition: base.Transition,
 1465        consequence: base.Consequence
 1466    ) -> None:
 1467        """
 1468        Replaces the transition consequence for the given transition at
 1469        the given decision. Any previous consequence is discarded. See
 1470        `Consequence` for the structure of these. Note that this does
 1471        NOT make a copy of the consequence, do that first to avoid
 1472        effect-entanglement if you're copying a consequence.
 1473
 1474        A `MissingDecisionError` or a `MissingTransitionError` is raised
 1475        if the specified decision/transition combination doesn't exist.
 1476        """
 1477        dID = self.resolveDecision(decision)
 1478
 1479        dest = self.destination(dID, transition)
 1480
 1481        info = cast(
 1482            TransitionProperties,
 1483            self.edges[dID, dest, transition]  # type:ignore
 1484        )
 1485
 1486        info['consequence'] = consequence
 1487
 1488    def addEquivalence(
 1489        self,
 1490        requirement: base.Requirement,
 1491        capabilityOrMechanismState: Union[
 1492            base.Capability,
 1493            Tuple[base.MechanismID, base.MechanismState]
 1494        ]
 1495    ) -> None:
 1496        """
 1497        Adds the given requirement as an equivalence for the given
 1498        capability or the given mechanism state. Note that having a
 1499        capability via an equivalence does not count as actually having
 1500        that capability; it only counts for the purpose of satisfying
 1501        `Requirement`s.
 1502
 1503        Note also that because a mechanism-based requirement looks up
 1504        the specific mechanism locally based on a name, an equivalence
 1505        defined in one location may affect mechanism requirements in
 1506        other locations unless the mechanism name in the requirement is
 1507        zone-qualified to be specific. But in such situations the base
 1508        mechanism would have caused issues in any case.
 1509        """
 1510        self.equivalences.setdefault(
 1511            capabilityOrMechanismState,
 1512            set()
 1513        ).add(requirement)
 1514
 1515    def removeEquivalence(
 1516        self,
 1517        requirement: base.Requirement,
 1518        capabilityOrMechanismState: Union[
 1519            base.Capability,
 1520            Tuple[base.MechanismID, base.MechanismState]
 1521        ]
 1522    ) -> None:
 1523        """
 1524        Removes an equivalence. Raises a `KeyError` if no such
 1525        equivalence existed.
 1526        """
 1527        self.equivalences[capabilityOrMechanismState].remove(requirement)
 1528
 1529    def hasAnyEquivalents(
 1530        self,
 1531        capabilityOrMechanismState: Union[
 1532            base.Capability,
 1533            Tuple[base.MechanismID, base.MechanismState]
 1534        ]
 1535    ) -> bool:
 1536        """
 1537        Returns `True` if the given capability or mechanism state has at
 1538        least one equivalence.
 1539        """
 1540        return capabilityOrMechanismState in self.equivalences
 1541
 1542    def allEquivalents(
 1543        self,
 1544        capabilityOrMechanismState: Union[
 1545            base.Capability,
 1546            Tuple[base.MechanismID, base.MechanismState]
 1547        ]
 1548    ) -> Set[base.Requirement]:
 1549        """
 1550        Returns the set of equivalences for the given capability. This is
 1551        a live set which may be modified (it's probably better to use
 1552        `addEquivalence` and `removeEquivalence` instead...).
 1553        """
 1554        return self.equivalences.setdefault(
 1555            capabilityOrMechanismState,
 1556            set()
 1557        )
 1558
 1559    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
 1560        """
 1561        Specifies a new reversion type, so that when used in a reversion
 1562        aspects set with a colon before the name, all items in the
 1563        `equivalentTo` value will be added to that set. These may
 1564        include other custom reversion type names (with the colon) but
 1565        take care not to create an equivalence loop which would result
 1566        in a crash.
 1567
 1568        If you re-use the same name, it will override the old equivalence
 1569        for that name.
 1570        """
 1571        self.reversionTypes[name] = equivalentTo
 1572
 1573    def addAction(
 1574        self,
 1575        decision: base.AnyDecisionSpecifier,
 1576        action: base.Transition,
 1577        requires: Optional[base.Requirement] = None,
 1578        consequence: Optional[base.Consequence] = None,
 1579        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 1580        annotations: Optional[List[base.Annotation]] = None,
 1581    ) -> None:
 1582        """
 1583        Adds the given action as a possibility at the given decision. An
 1584        action is just a self-edge, which can have requirements like any
 1585        edge, and which can have consequences like any edge.
 1586        The optional arguments are given to `setTransitionRequirement`
 1587        and `setConsequence`; see those functions for descriptions
 1588        of what they mean.
 1589
 1590        Raises a `KeyError` if a transition with the given name already
 1591        exists at the given decision.
 1592        """
 1593        if tags is None:
 1594            tags = {}
 1595        if annotations is None:
 1596            annotations = []
 1597
 1598        dID = self.resolveDecision(decision)
 1599
 1600        self.add_edge(
 1601            dID,
 1602            dID,
 1603            key=action,
 1604            tags=tags,
 1605            annotations=annotations
 1606        )
 1607        self.setTransitionRequirement(dID, action, requires)
 1608        if consequence is not None:
 1609            self.setConsequence(dID, action, consequence)
 1610
 1611    def tagDecision(
 1612        self,
 1613        decision: base.AnyDecisionSpecifier,
 1614        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 1615        tagValue: Union[
 1616            base.TagValue,
 1617            type[base.NoTagValue]
 1618        ] = base.NoTagValue
 1619    ) -> None:
 1620        """
 1621        Adds a tag (or many tags from a dictionary of tags) to a
 1622        decision, using `1` as the value if no value is provided. It's
 1623        a `ValueError` to provide a value when a dictionary of tags is
 1624        provided to set multiple tags at once.
 1625
 1626        Note that certain tags have special meanings:
 1627
 1628        - 'unconfirmed' is used for decisions that represent unconfirmed
 1629            parts of the graph (this is separate from the 'unknown'
 1630            and/or 'hypothesized' exploration statuses, which are only
 1631            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
 1632            Various methods require this tag and many also add or remove
 1633            it.
 1634        """
 1635        if isinstance(tagOrTags, base.Tag):
 1636            if tagValue is base.NoTagValue:
 1637                tagValue = 1
 1638
 1639            # Not sure why this cast is necessary given the `if` above...
 1640            tagValue = cast(base.TagValue, tagValue)
 1641
 1642            tagOrTags = {tagOrTags: tagValue}
 1643
 1644        elif tagValue is not base.NoTagValue:
 1645            raise ValueError(
 1646                "Provided a dictionary to update multiple tags, but"
 1647                " also a tag value."
 1648            )
 1649
 1650        dID = self.resolveDecision(decision)
 1651
 1652        tagsAlready = self.nodes[dID].setdefault('tags', {})
 1653        tagsAlready.update(tagOrTags)
 1654
 1655    def untagDecision(
 1656        self,
 1657        decision: base.AnyDecisionSpecifier,
 1658        tag: base.Tag
 1659    ) -> Union[base.TagValue, type[base.NoTagValue]]:
 1660        """
 1661        Removes a tag from a decision. Returns the tag's old value if
 1662        the tag was present and got removed, or `NoTagValue` if the tag
 1663        wasn't present.
 1664        """
 1665        dID = self.resolveDecision(decision)
 1666
 1667        target = self.nodes[dID]['tags']
 1668        try:
 1669            return target.pop(tag)
 1670        except KeyError:
 1671            return base.NoTagValue
 1672
 1673    def decisionTags(
 1674        self,
 1675        decision: base.AnyDecisionSpecifier
 1676    ) -> Dict[base.Tag, base.TagValue]:
 1677        """
 1678        Returns the dictionary of tags for a decision. Edits to the
 1679        returned value will be applied to the graph.
 1680        """
 1681        dID = self.resolveDecision(decision)
 1682
 1683        return self.nodes[dID]['tags']
 1684
 1685    def annotateDecision(
 1686        self,
 1687        decision: base.AnyDecisionSpecifier,
 1688        annotationOrAnnotations: Union[
 1689            base.Annotation,
 1690            Sequence[base.Annotation]
 1691        ]
 1692    ) -> None:
 1693        """
 1694        Adds an annotation to a decision's annotations list.
 1695        """
 1696        dID = self.resolveDecision(decision)
 1697
 1698        if isinstance(annotationOrAnnotations, base.Annotation):
 1699            annotationOrAnnotations = [annotationOrAnnotations]
 1700        self.nodes[dID]['annotations'].extend(annotationOrAnnotations)
 1701
 1702    def decisionAnnotations(
 1703        self,
 1704        decision: base.AnyDecisionSpecifier
 1705    ) -> List[base.Annotation]:
 1706        """
 1707        Returns the list of annotations for the specified decision.
 1708        Modifying the list affects the graph.
 1709        """
 1710        dID = self.resolveDecision(decision)
 1711
 1712        return self.nodes[dID]['annotations']
 1713
 1714    def tagTransition(
 1715        self,
 1716        decision: base.AnyDecisionSpecifier,
 1717        transition: base.Transition,
 1718        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 1719        tagValue: Union[
 1720            base.TagValue,
 1721            type[base.NoTagValue]
 1722        ] = base.NoTagValue
 1723    ) -> None:
 1724        """
 1725        Adds a tag (or each tag from a dictionary) to a transition
 1726        coming out of a specific decision. `1` will be used as the
 1727        default value if a single tag is supplied; supplying a tag value
 1728        when providing a dictionary of multiple tags to update is a
 1729        `ValueError`.
 1730
 1731        Note that certain transition tags have special meanings:
 1732        - 'trigger' causes any actions (but not normal transitions) that
 1733            it applies to to be automatically triggered when
 1734            `advanceSituation` is called and the decision they're
 1735            attached to is active in the new situation (as long as the
 1736            action's requirements are met). This happens once per
 1737            situation; use 'wait' steps to re-apply triggers.
 1738        """
 1739        dID = self.resolveDecision(decision)
 1740
 1741        dest = self.destination(dID, transition)
 1742        if isinstance(tagOrTags, base.Tag):
 1743            if tagValue is base.NoTagValue:
 1744                tagValue = 1
 1745
 1746            # Not sure why this is necessary given the `if` above...
 1747            tagValue = cast(base.TagValue, tagValue)
 1748
 1749            tagOrTags = {tagOrTags: tagValue}
 1750        elif tagValue is not base.NoTagValue:
 1751            raise ValueError(
 1752                "Provided a dictionary to update multiple tags, but"
 1753                " also a tag value."
 1754            )
 1755
 1756        info = cast(
 1757            TransitionProperties,
 1758            self.edges[dID, dest, transition]  # type:ignore
 1759        )
 1760
 1761        info.setdefault('tags', {}).update(tagOrTags)
 1762
 1763    def untagTransition(
 1764        self,
 1765        decision: base.AnyDecisionSpecifier,
 1766        transition: base.Transition,
 1767        tagOrTags: Union[base.Tag, Set[base.Tag]]
 1768    ) -> None:
 1769        """
 1770        Removes a tag (or each tag in a set) from a transition coming out
 1771        of a specific decision. Raises a `KeyError` if (one of) the
 1772        specified tag(s) is not currently applied to the specified
 1773        transition.
 1774        """
 1775        dID = self.resolveDecision(decision)
 1776
 1777        dest = self.destination(dID, transition)
 1778        if isinstance(tagOrTags, base.Tag):
 1779            tagOrTags = {tagOrTags}
 1780
 1781        info = cast(
 1782            TransitionProperties,
 1783            self.edges[dID, dest, transition]  # type:ignore
 1784        )
 1785        tagsAlready = info.setdefault('tags', {})
 1786
 1787        for tag in tagOrTags:
 1788            tagsAlready.pop(tag)
 1789
 1790    def transitionTags(
 1791        self,
 1792        decision: base.AnyDecisionSpecifier,
 1793        transition: base.Transition
 1794    ) -> Dict[base.Tag, base.TagValue]:
 1795        """
 1796        Returns the dictionary of tags for a transition. Edits to the
 1797        returned dictionary will be applied to the graph.
 1798        """
 1799        dID = self.resolveDecision(decision)
 1800
 1801        dest = self.destination(dID, transition)
 1802        info = cast(
 1803            TransitionProperties,
 1804            self.edges[dID, dest, transition]  # type:ignore
 1805        )
 1806        return info.setdefault('tags', {})
 1807
 1808    def annotateTransition(
 1809        self,
 1810        decision: base.AnyDecisionSpecifier,
 1811        transition: base.Transition,
 1812        annotations: Union[base.Annotation, Sequence[base.Annotation]]
 1813    ) -> None:
 1814        """
 1815        Adds an annotation (or a sequence of annotations) to a
 1816        transition's annotations list.
 1817        """
 1818        dID = self.resolveDecision(decision)
 1819
 1820        dest = self.destination(dID, transition)
 1821        if isinstance(annotations, base.Annotation):
 1822            annotations = [annotations]
 1823        info = cast(
 1824            TransitionProperties,
 1825            self.edges[dID, dest, transition]  # type:ignore
 1826        )
 1827        info['annotations'].extend(annotations)
 1828
 1829    def transitionAnnotations(
 1830        self,
 1831        decision: base.AnyDecisionSpecifier,
 1832        transition: base.Transition
 1833    ) -> List[base.Annotation]:
 1834        """
 1835        Returns the annotation list for a specific transition at a
 1836        specific decision. Editing the list affects the graph.
 1837        """
 1838        dID = self.resolveDecision(decision)
 1839
 1840        dest = self.destination(dID, transition)
 1841        info = cast(
 1842            TransitionProperties,
 1843            self.edges[dID, dest, transition]  # type:ignore
 1844        )
 1845        return info['annotations']
 1846
 1847    def annotateZone(
 1848        self,
 1849        zone: base.Zone,
 1850        annotations: Union[base.Annotation, Sequence[base.Annotation]]
 1851    ) -> None:
 1852        """
 1853        Adds an annotation (or many annotations from a sequence) to a
 1854        zone.
 1855
 1856        Raises a `MissingZoneError` if the specified zone does not exist.
 1857        """
 1858        if zone not in self.zones:
 1859            raise MissingZoneError(
 1860                f"Can't add annotation(s) to zone {zone!r} because that"
 1861                f" zone doesn't exist yet."
 1862            )
 1863
 1864        if isinstance(annotations, base.Annotation):
 1865            annotations = [ annotations ]
 1866
 1867        self.zones[zone].annotations.extend(annotations)
 1868
 1869    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
 1870        """
 1871        Returns the list of annotations for the specified zone (empty if
 1872        none have been added yet).
 1873        """
 1874        return self.zones[zone].annotations
 1875
 1876    def tagZone(
 1877        self,
 1878        zone: base.Zone,
 1879        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 1880        tagValue: Union[
 1881            base.TagValue,
 1882            type[base.NoTagValue]
 1883        ] = base.NoTagValue
 1884    ) -> None:
 1885        """
 1886        Adds a tag (or many tags from a dictionary of tags) to a
 1887        zone, using `1` as the value if no value is provided. It's
 1888        a `ValueError` to provide a value when a dictionary of tags is
 1889        provided to set multiple tags at once.
 1890
 1891        Raises a `MissingZoneError` if the specified zone does not exist.
 1892        """
 1893        if zone not in self.zones:
 1894            raise MissingZoneError(
 1895                f"Can't add tag(s) to zone {zone!r} because that zone"
 1896                f" doesn't exist yet."
 1897            )
 1898
 1899        if isinstance(tagOrTags, base.Tag):
 1900            if tagValue is base.NoTagValue:
 1901                tagValue = 1
 1902
 1903            # Not sure why this cast is necessary given the `if` above...
 1904            tagValue = cast(base.TagValue, tagValue)
 1905
 1906            tagOrTags = {tagOrTags: tagValue}
 1907
 1908        elif tagValue is not base.NoTagValue:
 1909            raise ValueError(
 1910                "Provided a dictionary to update multiple tags, but"
 1911                " also a tag value."
 1912            )
 1913
 1914        tagsAlready = self.zones[zone].tags
 1915        tagsAlready.update(tagOrTags)
 1916
 1917    def untagZone(
 1918        self,
 1919        zone: base.Zone,
 1920        tag: base.Tag
 1921    ) -> Union[base.TagValue, type[base.NoTagValue]]:
 1922        """
 1923        Removes a tag from a zone. Returns the tag's old value if the
 1924        tag was present and got removed, or `NoTagValue` if the tag
 1925        wasn't present.
 1926
 1927        Raises a `MissingZoneError` if the specified zone does not exist.
 1928        """
 1929        if zone not in self.zones:
 1930            raise MissingZoneError(
 1931                f"Can't remove tag {tag!r} from zone {zone!r} because"
 1932                f" that zone doesn't exist yet."
 1933            )
 1934        target = self.zones[zone].tags
 1935        try:
 1936            return target.pop(tag)
 1937        except KeyError:
 1938            return base.NoTagValue
 1939
 1940    def zoneTags(
 1941        self,
 1942        zone: base.Zone
 1943    ) -> Dict[base.Tag, base.TagValue]:
 1944        """
 1945        Returns the dictionary of tags for a zone. Edits to the returned
 1946        value will be applied to the graph. Returns an empty tags
 1947        dictionary if called on a zone that didn't have any tags
 1948        previously, but raises a `MissingZoneError` if attempting to get
 1949        tags for a zone which does not exist.
 1950
 1951        For example:
 1952
 1953        >>> g = DecisionGraph()
 1954        >>> g.addDecision('A')
 1955        0
 1956        >>> g.addDecision('B')
 1957        1
 1958        >>> g.createZone('Zone')
 1959        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1960 annotations=[])
 1961        >>> g.tagZone('Zone', 'color', 'blue')
 1962        >>> g.tagZone(
 1963        ...     'Zone',
 1964        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
 1965        ... )
 1966        >>> g.untagZone('Zone', 'sound')
 1967        'loud'
 1968        >>> g.zoneTags('Zone')
 1969        {'color': 'red', 'shape': 'square'}
 1970        """
 1971        if zone in self.zones:
 1972            return self.zones[zone].tags
 1973        else:
 1974            raise MissingZoneError(
 1975                f"Tags for zone {zone!r} don't exist because that"
 1976                f" zone has not been created yet."
 1977            )
 1978
 1979    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
 1980        """
 1981        Creates an empty zone with the given name at the given level
 1982        (default 0). Raises a `ZoneCollisionError` if that zone name is
 1983        already in use (at any level), including if it's in use by a
 1984        decision.
 1985
 1986        Raises an `InvalidLevelError` if the level value is less than 0.
 1987
 1988        Returns the `ZoneInfo` for the new blank zone.
 1989
 1990        For example:
 1991
 1992        >>> d = DecisionGraph()
 1993        >>> d.createZone('Z', 0)
 1994        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1995 annotations=[])
 1996        >>> d.getZoneInfo('Z')
 1997        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1998 annotations=[])
 1999        >>> d.createZone('Z2', 0)
 2000        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2001 annotations=[])
 2002        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
 2003        Traceback (most recent call last):
 2004        ...
 2005        exploration.core.InvalidLevelError...
 2006        >>> d.createZone('Z2')  # Name Z2 is already in use
 2007        Traceback (most recent call last):
 2008        ...
 2009        exploration.core.ZoneCollisionError...
 2010        """
 2011        if level < 0:
 2012            raise InvalidLevelError(
 2013                "Cannot create a zone with a negative level."
 2014            )
 2015        if zone in self.zones:
 2016            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
 2017        if zone in self:
 2018            raise ZoneCollisionError(
 2019                f"A decision named {zone!r} already exists, so a zone"
 2020                f" with that name cannot be created."
 2021            )
 2022        info: base.ZoneInfo = base.ZoneInfo(
 2023            level=level,
 2024            parents=set(),
 2025            contents=set(),
 2026            tags={},
 2027            annotations=[]
 2028        )
 2029        self.zones[zone] = info
 2030        return info
 2031
 2032    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
 2033        """
 2034        Returns the `ZoneInfo` (level, parents, and contents) for the
 2035        specified zone, or `None` if that zone does not exist.
 2036
 2037        For example:
 2038
 2039        >>> d = DecisionGraph()
 2040        >>> d.createZone('Z', 0)
 2041        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2042 annotations=[])
 2043        >>> d.getZoneInfo('Z')
 2044        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2045 annotations=[])
 2046        >>> d.createZone('Z2', 0)
 2047        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2048 annotations=[])
 2049        >>> d.getZoneInfo('Z2')
 2050        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2051 annotations=[])
 2052        """
 2053        return self.zones.get(zone)
 2054
 2055    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
 2056        """
 2057        Deletes the specified zone, returning a `ZoneInfo` object with
 2058        the information on the level, parents, and contents of that zone.
 2059
 2060        Raises a `MissingZoneError` if the zone in question does not
 2061        exist.
 2062
 2063        For example:
 2064
 2065        >>> d = DecisionGraph()
 2066        >>> d.createZone('Z', 0)
 2067        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2068 annotations=[])
 2069        >>> d.getZoneInfo('Z')
 2070        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2071 annotations=[])
 2072        >>> d.deleteZone('Z')
 2073        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2074 annotations=[])
 2075        >>> d.getZoneInfo('Z') is None  # no info any more
 2076        True
 2077        >>> d.deleteZone('Z')  # can't re-delete
 2078        Traceback (most recent call last):
 2079        ...
 2080        exploration.core.MissingZoneError...
 2081        """
 2082        info = self.getZoneInfo(zone)
 2083        if info is None:
 2084            raise MissingZoneError(
 2085                f"Cannot delete zone {zone!r}: it does not exist."
 2086            )
 2087        for sub in info.contents:
 2088            if 'zones' in self.nodes[sub]:
 2089                try:
 2090                    self.nodes[sub]['zones'].remove(zone)
 2091                except KeyError:
 2092                    pass
 2093        del self.zones[zone]
 2094        return info
 2095
 2096    def addDecisionToZone(
 2097        self,
 2098        decision: base.AnyDecisionSpecifier,
 2099        zone: base.Zone
 2100    ) -> None:
 2101        """
 2102        Adds a decision directly to a zone. Should normally only be used
 2103        with level-0 zones. Raises a `MissingZoneError` if the specified
 2104        zone did not already exist.
 2105
 2106        For example:
 2107
 2108        >>> d = DecisionGraph()
 2109        >>> d.addDecision('A')
 2110        0
 2111        >>> d.addDecision('B')
 2112        1
 2113        >>> d.addDecision('C')
 2114        2
 2115        >>> d.createZone('Z', 0)
 2116        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2117 annotations=[])
 2118        >>> d.addDecisionToZone('A', 'Z')
 2119        >>> d.getZoneInfo('Z')
 2120        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
 2121 annotations=[])
 2122        >>> d.addDecisionToZone('B', 'Z')
 2123        >>> d.getZoneInfo('Z')
 2124        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2125 annotations=[])
 2126        """
 2127        dID = self.resolveDecision(decision)
 2128
 2129        if zone not in self.zones:
 2130            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2131
 2132        self.zones[zone].contents.add(dID)
 2133        self.nodes[dID].setdefault('zones', set()).add(zone)
 2134
 2135    def removeDecisionFromZone(
 2136        self,
 2137        decision: base.AnyDecisionSpecifier,
 2138        zone: base.Zone
 2139    ) -> bool:
 2140        """
 2141        Removes a decision from a zone if it had been in it, returning
 2142        True if that decision had been in that zone, and False if it was
 2143        not in that zone, including if that zone didn't exist.
 2144
 2145        Note that this only removes a decision from direct zone
 2146        membership. If the decision is a member of one or more zones
 2147        which are (directly or indirectly) sub-zones of the target zone,
 2148        the decision will remain in those zones, and will still be
 2149        indirectly part of the target zone afterwards.
 2150
 2151        Examples:
 2152
 2153        >>> g = DecisionGraph()
 2154        >>> g.addDecision('A')
 2155        0
 2156        >>> g.addDecision('B')
 2157        1
 2158        >>> g.createZone('level0', 0)
 2159        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2160 annotations=[])
 2161        >>> g.createZone('level1', 1)
 2162        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2163 annotations=[])
 2164        >>> g.createZone('level2', 2)
 2165        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2166 annotations=[])
 2167        >>> g.createZone('level3', 3)
 2168        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2169 annotations=[])
 2170        >>> g.addDecisionToZone('A', 'level0')
 2171        >>> g.addDecisionToZone('B', 'level0')
 2172        >>> g.addZoneToZone('level0', 'level1')
 2173        >>> g.addZoneToZone('level1', 'level2')
 2174        >>> g.addZoneToZone('level2', 'level3')
 2175        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 2176        >>> g.removeDecisionFromZone('A', 'level1')
 2177        False
 2178        >>> g.zoneParents(0)
 2179        {'level0'}
 2180        >>> g.removeDecisionFromZone('A', 'level0')
 2181        True
 2182        >>> g.zoneParents(0)
 2183        set()
 2184        >>> g.removeDecisionFromZone('A', 'level0')
 2185        False
 2186        >>> g.removeDecisionFromZone('B', 'level0')
 2187        True
 2188        >>> g.zoneParents(1)
 2189        {'level2'}
 2190        >>> g.removeDecisionFromZone('B', 'level0')
 2191        False
 2192        >>> g.removeDecisionFromZone('B', 'level2')
 2193        True
 2194        >>> g.zoneParents(1)
 2195        set()
 2196        """
 2197        dID = self.resolveDecision(decision)
 2198
 2199        if zone not in self.zones:
 2200            return False
 2201
 2202        info = self.zones[zone]
 2203        if dID not in info.contents:
 2204            return False
 2205        else:
 2206            info.contents.remove(dID)
 2207            try:
 2208                self.nodes[dID]['zones'].remove(zone)
 2209            except KeyError:
 2210                pass
 2211            return True
 2212
 2213    def addZoneToZone(
 2214        self,
 2215        addIt: base.Zone,
 2216        addTo: base.Zone
 2217    ) -> None:
 2218        """
 2219        Adds a zone to another zone. The `addIt` one must be at a
 2220        strictly lower level than the `addTo` zone, or an
 2221        `InvalidLevelError` will be raised.
 2222
 2223        If the zone to be added didn't already exist, it is created at
 2224        one level below the target zone. Similarly, if the zone being
 2225        added to didn't already exist, it is created at one level above
 2226        the target zone. If neither existed, a `MissingZoneError` will
 2227        be raised.
 2228
 2229        For example:
 2230
 2231        >>> d = DecisionGraph()
 2232        >>> d.addDecision('A')
 2233        0
 2234        >>> d.addDecision('B')
 2235        1
 2236        >>> d.addDecision('C')
 2237        2
 2238        >>> d.createZone('Z', 0)
 2239        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2240 annotations=[])
 2241        >>> d.addDecisionToZone('A', 'Z')
 2242        >>> d.addDecisionToZone('B', 'Z')
 2243        >>> d.getZoneInfo('Z')
 2244        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2245 annotations=[])
 2246        >>> d.createZone('Z2', 0)
 2247        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2248 annotations=[])
 2249        >>> d.addDecisionToZone('B', 'Z2')
 2250        >>> d.addDecisionToZone('C', 'Z2')
 2251        >>> d.getZoneInfo('Z2')
 2252        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2253 annotations=[])
 2254        >>> d.createZone('l1Z', 1)
 2255        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2256 annotations=[])
 2257        >>> d.createZone('l2Z', 2)
 2258        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2259 annotations=[])
 2260        >>> d.addZoneToZone('Z', 'l1Z')
 2261        >>> d.getZoneInfo('Z')
 2262        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
 2263 annotations=[])
 2264        >>> d.getZoneInfo('l1Z')
 2265        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2266 annotations=[])
 2267        >>> d.addZoneToZone('l1Z', 'l2Z')
 2268        >>> d.getZoneInfo('l1Z')
 2269        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
 2270 annotations=[])
 2271        >>> d.getZoneInfo('l2Z')
 2272        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
 2273 annotations=[])
 2274        >>> d.addZoneToZone('Z2', 'l2Z')
 2275        >>> d.getZoneInfo('Z2')
 2276        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
 2277 annotations=[])
 2278        >>> l2i = d.getZoneInfo('l2Z')
 2279        >>> l2i.level
 2280        2
 2281        >>> l2i.parents
 2282        set()
 2283        >>> sorted(l2i.contents)
 2284        ['Z2', 'l1Z']
 2285        >>> d.addZoneToZone('NZ', 'NZ2')
 2286        Traceback (most recent call last):
 2287        ...
 2288        exploration.core.MissingZoneError...
 2289        >>> d.addZoneToZone('Z', 'l1Z2')
 2290        >>> zi = d.getZoneInfo('Z')
 2291        >>> zi.level
 2292        0
 2293        >>> sorted(zi.parents)
 2294        ['l1Z', 'l1Z2']
 2295        >>> sorted(zi.contents)
 2296        [0, 1]
 2297        >>> d.getZoneInfo('l1Z2')
 2298        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2299 annotations=[])
 2300        >>> d.addZoneToZone('NZ', 'l1Z')
 2301        >>> d.getZoneInfo('NZ')
 2302        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
 2303 annotations=[])
 2304        >>> zi = d.getZoneInfo('l1Z')
 2305        >>> zi.level
 2306        1
 2307        >>> zi.parents
 2308        {'l2Z'}
 2309        >>> sorted(zi.contents)
 2310        ['NZ', 'Z']
 2311        """
 2312        # Create one or the other (but not both) if they're missing
 2313        addInfo = self.getZoneInfo(addIt)
 2314        toInfo = self.getZoneInfo(addTo)
 2315        if addInfo is None and toInfo is None:
 2316            raise MissingZoneError(
 2317                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
 2318                f" exists already."
 2319            )
 2320
 2321        # Create missing addIt
 2322        elif addInfo is None:
 2323            toInfo = cast(base.ZoneInfo, toInfo)
 2324            newLevel = toInfo.level - 1
 2325            if newLevel < 0:
 2326                raise InvalidLevelError(
 2327                    f"Zone {addTo!r} is at level {toInfo.level} and so"
 2328                    f" a new zone cannot be added underneath it."
 2329                )
 2330            addInfo = self.createZone(addIt, newLevel)
 2331
 2332        # Create missing addTo
 2333        elif toInfo is None:
 2334            addInfo = cast(base.ZoneInfo, addInfo)
 2335            newLevel = addInfo.level + 1
 2336            if newLevel < 0:
 2337                raise InvalidLevelError(
 2338                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
 2339                    f" and so a new zone cannot be added above it."
 2340                )
 2341            toInfo = self.createZone(addTo, newLevel)
 2342
 2343        # Now both addInfo and toInfo are defined
 2344        if addInfo.level >= toInfo.level:
 2345            raise InvalidLevelError(
 2346                f"Cannot add zone {addIt!r} at level {addInfo.level}"
 2347                f" to zone {addTo!r} at level {toInfo.level}: zones can"
 2348                f" only contain zones of lower levels."
 2349            )
 2350
 2351        # Now both addInfo and toInfo are defined
 2352        toInfo.contents.add(addIt)
 2353        addInfo.parents.add(addTo)
 2354
 2355    def removeZoneFromZone(
 2356        self,
 2357        removeIt: base.Zone,
 2358        removeFrom: base.Zone
 2359    ) -> bool:
 2360        """
 2361        Removes a zone from a zone if it had been in it, returning True
 2362        if that zone had been in that zone, and False if it was not in
 2363        that zone, including if either zone did not exist.
 2364
 2365        For example:
 2366
 2367        >>> d = DecisionGraph()
 2368        >>> d.createZone('Z', 0)
 2369        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2370 annotations=[])
 2371        >>> d.createZone('Z2', 0)
 2372        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2373 annotations=[])
 2374        >>> d.createZone('l1Z', 1)
 2375        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2376 annotations=[])
 2377        >>> d.createZone('l2Z', 2)
 2378        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2379 annotations=[])
 2380        >>> d.addZoneToZone('Z', 'l1Z')
 2381        >>> d.addZoneToZone('l1Z', 'l2Z')
 2382        >>> d.getZoneInfo('Z')
 2383        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
 2384 annotations=[])
 2385        >>> d.getZoneInfo('l1Z')
 2386        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
 2387 annotations=[])
 2388        >>> d.getZoneInfo('l2Z')
 2389        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
 2390 annotations=[])
 2391        >>> d.removeZoneFromZone('l1Z', 'l2Z')
 2392        True
 2393        >>> d.getZoneInfo('l1Z')
 2394        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2395 annotations=[])
 2396        >>> d.getZoneInfo('l2Z')
 2397        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2398 annotations=[])
 2399        >>> d.removeZoneFromZone('Z', 'l1Z')
 2400        True
 2401        >>> d.getZoneInfo('Z')
 2402        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2403 annotations=[])
 2404        >>> d.getZoneInfo('l1Z')
 2405        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2406 annotations=[])
 2407        >>> d.removeZoneFromZone('Z', 'l1Z')
 2408        False
 2409        >>> d.removeZoneFromZone('Z', 'madeup')
 2410        False
 2411        >>> d.removeZoneFromZone('nope', 'madeup')
 2412        False
 2413        >>> d.removeZoneFromZone('nope', 'l1Z')
 2414        False
 2415        """
 2416        remInfo = self.getZoneInfo(removeIt)
 2417        fromInfo = self.getZoneInfo(removeFrom)
 2418
 2419        if remInfo is None or fromInfo is None:
 2420            return False
 2421
 2422        if removeIt not in fromInfo.contents:
 2423            return False
 2424
 2425        remInfo.parents.remove(removeFrom)
 2426        fromInfo.contents.remove(removeIt)
 2427        return True
 2428
 2429    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
 2430        """
 2431        Returns a set of all decisions included directly in the given
 2432        zone, not counting decisions included via intermediate
 2433        sub-zones (see `allDecisionsInZone` to include those).
 2434
 2435        Raises a `MissingZoneError` if the specified zone does not
 2436        exist.
 2437
 2438        The returned set is a copy, not a live editable set.
 2439
 2440        For example:
 2441
 2442        >>> d = DecisionGraph()
 2443        >>> d.addDecision('A')
 2444        0
 2445        >>> d.addDecision('B')
 2446        1
 2447        >>> d.addDecision('C')
 2448        2
 2449        >>> d.createZone('Z', 0)
 2450        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2451 annotations=[])
 2452        >>> d.addDecisionToZone('A', 'Z')
 2453        >>> d.addDecisionToZone('B', 'Z')
 2454        >>> d.getZoneInfo('Z')
 2455        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2456 annotations=[])
 2457        >>> d.decisionsInZone('Z')
 2458        {0, 1}
 2459        >>> d.createZone('Z2', 0)
 2460        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2461 annotations=[])
 2462        >>> d.addDecisionToZone('B', 'Z2')
 2463        >>> d.addDecisionToZone('C', 'Z2')
 2464        >>> d.getZoneInfo('Z2')
 2465        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2466 annotations=[])
 2467        >>> d.decisionsInZone('Z')
 2468        {0, 1}
 2469        >>> d.decisionsInZone('Z2')
 2470        {1, 2}
 2471        >>> d.createZone('l1Z', 1)
 2472        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2473 annotations=[])
 2474        >>> d.addZoneToZone('Z', 'l1Z')
 2475        >>> d.decisionsInZone('Z')
 2476        {0, 1}
 2477        >>> d.decisionsInZone('l1Z')
 2478        set()
 2479        >>> d.decisionsInZone('madeup')
 2480        Traceback (most recent call last):
 2481        ...
 2482        exploration.core.MissingZoneError...
 2483        >>> zDec = d.decisionsInZone('Z')
 2484        >>> zDec.add(2)  # won't affect the zone
 2485        >>> zDec
 2486        {0, 1, 2}
 2487        >>> d.decisionsInZone('Z')
 2488        {0, 1}
 2489        """
 2490        info = self.getZoneInfo(zone)
 2491        if info is None:
 2492            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2493
 2494        # Everything that's not a zone must be a decision
 2495        return {
 2496            item
 2497            for item in info.contents
 2498            if isinstance(item, base.DecisionID)
 2499        }
 2500
 2501    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
 2502        """
 2503        Returns the set of all immediate sub-zones of the given zone.
 2504        Will be an empty set if there are no sub-zones; raises a
 2505        `MissingZoneError` if the specified zone does not exit.
 2506
 2507        The returned set is a copy, not a live editable set.
 2508
 2509        For example:
 2510
 2511        >>> d = DecisionGraph()
 2512        >>> d.createZone('Z', 0)
 2513        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2514 annotations=[])
 2515        >>> d.subZones('Z')
 2516        set()
 2517        >>> d.createZone('l1Z', 1)
 2518        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2519 annotations=[])
 2520        >>> d.addZoneToZone('Z', 'l1Z')
 2521        >>> d.subZones('Z')
 2522        set()
 2523        >>> d.subZones('l1Z')
 2524        {'Z'}
 2525        >>> s = d.subZones('l1Z')
 2526        >>> s.add('Q')  # doesn't affect the zone
 2527        >>> sorted(s)
 2528        ['Q', 'Z']
 2529        >>> d.subZones('l1Z')
 2530        {'Z'}
 2531        >>> d.subZones('madeup')
 2532        Traceback (most recent call last):
 2533        ...
 2534        exploration.core.MissingZoneError...
 2535        """
 2536        info = self.getZoneInfo(zone)
 2537        if info is None:
 2538            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2539
 2540        # Sub-zones will appear in self.zones
 2541        return {
 2542            item
 2543            for item in info.contents
 2544            if isinstance(item, base.Zone)
 2545        }
 2546
 2547    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
 2548        """
 2549        Returns a set containing all decisions in the given zone,
 2550        including those included via sub-zones.
 2551
 2552        Raises a `MissingZoneError` if the specified zone does not
 2553        exist.`
 2554
 2555        For example:
 2556
 2557        >>> d = DecisionGraph()
 2558        >>> d.addDecision('A')
 2559        0
 2560        >>> d.addDecision('B')
 2561        1
 2562        >>> d.addDecision('C')
 2563        2
 2564        >>> d.createZone('Z', 0)
 2565        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2566 annotations=[])
 2567        >>> d.addDecisionToZone('A', 'Z')
 2568        >>> d.addDecisionToZone('B', 'Z')
 2569        >>> d.getZoneInfo('Z')
 2570        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2571 annotations=[])
 2572        >>> d.decisionsInZone('Z')
 2573        {0, 1}
 2574        >>> d.allDecisionsInZone('Z')
 2575        {0, 1}
 2576        >>> d.createZone('Z2', 0)
 2577        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2578 annotations=[])
 2579        >>> d.addDecisionToZone('B', 'Z2')
 2580        >>> d.addDecisionToZone('C', 'Z2')
 2581        >>> d.getZoneInfo('Z2')
 2582        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2583 annotations=[])
 2584        >>> d.decisionsInZone('Z')
 2585        {0, 1}
 2586        >>> d.decisionsInZone('Z2')
 2587        {1, 2}
 2588        >>> d.allDecisionsInZone('Z2')
 2589        {1, 2}
 2590        >>> d.createZone('l1Z', 1)
 2591        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2592 annotations=[])
 2593        >>> d.createZone('l2Z', 2)
 2594        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2595 annotations=[])
 2596        >>> d.addZoneToZone('Z', 'l1Z')
 2597        >>> d.addZoneToZone('l1Z', 'l2Z')
 2598        >>> d.addZoneToZone('Z2', 'l2Z')
 2599        >>> d.decisionsInZone('Z')
 2600        {0, 1}
 2601        >>> d.decisionsInZone('Z2')
 2602        {1, 2}
 2603        >>> d.decisionsInZone('l1Z')
 2604        set()
 2605        >>> d.allDecisionsInZone('l1Z')
 2606        {0, 1}
 2607        >>> d.allDecisionsInZone('l2Z')
 2608        {0, 1, 2}
 2609        """
 2610        result: Set[base.DecisionID] = set()
 2611        info = self.getZoneInfo(zone)
 2612        if info is None:
 2613            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2614
 2615        for item in info.contents:
 2616            if isinstance(item, base.Zone):
 2617                # This can't be an error because of the condition above
 2618                result |= self.allDecisionsInZone(item)
 2619            else:  # it's a decision
 2620                result.add(item)
 2621
 2622        return result
 2623
 2624    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
 2625        """
 2626        Returns the hierarchy level of the given zone, as stored in its
 2627        zone info.
 2628
 2629        By convention, level-0 zones contain decisions directly, and
 2630        higher-level zones contain zones of lower levels. This
 2631        convention is not enforced, and there could be exceptions to it.
 2632
 2633        Raises a `MissingZoneError` if the specified zone does not
 2634        exist.
 2635
 2636        For example:
 2637
 2638        >>> d = DecisionGraph()
 2639        >>> d.createZone('Z', 0)
 2640        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2641 annotations=[])
 2642        >>> d.createZone('l1Z', 1)
 2643        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2644 annotations=[])
 2645        >>> d.createZone('l5Z', 5)
 2646        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
 2647 annotations=[])
 2648        >>> d.zoneHierarchyLevel('Z')
 2649        0
 2650        >>> d.zoneHierarchyLevel('l1Z')
 2651        1
 2652        >>> d.zoneHierarchyLevel('l5Z')
 2653        5
 2654        >>> d.zoneHierarchyLevel('madeup')
 2655        Traceback (most recent call last):
 2656        ...
 2657        exploration.core.MissingZoneError...
 2658        """
 2659        info = self.getZoneInfo(zone)
 2660        if info is None:
 2661            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
 2662
 2663        return info.level
 2664
 2665    def zoneParents(
 2666        self,
 2667        zoneOrDecision: Union[base.Zone, base.DecisionID]
 2668    ) -> Set[base.Zone]:
 2669        """
 2670        Returns the set of all zones which directly contain the target
 2671        zone or decision.
 2672
 2673        Raises a `MissingDecisionError` if the target is neither a valid
 2674        zone nor a valid decision.
 2675
 2676        Returns a copy, not a live editable set.
 2677
 2678        Example:
 2679
 2680        >>> g = DecisionGraph()
 2681        >>> g.addDecision('A')
 2682        0
 2683        >>> g.addDecision('B')
 2684        1
 2685        >>> g.createZone('level0', 0)
 2686        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2687 annotations=[])
 2688        >>> g.createZone('level1', 1)
 2689        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2690 annotations=[])
 2691        >>> g.createZone('level2', 2)
 2692        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2693 annotations=[])
 2694        >>> g.createZone('level3', 3)
 2695        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2696 annotations=[])
 2697        >>> g.addDecisionToZone('A', 'level0')
 2698        >>> g.addDecisionToZone('B', 'level0')
 2699        >>> g.addZoneToZone('level0', 'level1')
 2700        >>> g.addZoneToZone('level1', 'level2')
 2701        >>> g.addZoneToZone('level2', 'level3')
 2702        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 2703        >>> sorted(g.zoneParents(0))
 2704        ['level0']
 2705        >>> sorted(g.zoneParents(1))
 2706        ['level0', 'level2']
 2707        """
 2708        if zoneOrDecision in self.zones:
 2709            zoneOrDecision = cast(base.Zone, zoneOrDecision)
 2710            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
 2711            return copy.copy(info.parents)
 2712        elif zoneOrDecision in self:
 2713            return self.nodes[zoneOrDecision].get('zones', set())
 2714        else:
 2715            raise MissingDecisionError(
 2716                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
 2717                f" valid decision."
 2718            )
 2719
 2720    def zoneAncestors(
 2721        self,
 2722        zoneOrDecision: Union[base.Zone, base.DecisionID],
 2723        exclude: Set[base.Zone] = set(),
 2724        atLevel: Optional[int] = None
 2725    ) -> Set[base.Zone]:
 2726        """
 2727        Returns the set of zones which contain the target zone or
 2728        decision, either directly or indirectly. The target is not
 2729        included in the set.
 2730
 2731        Any ones listed in the `exclude` set are also excluded, as are
 2732        any of their ancestors which are not also ancestors of the
 2733        target zone via another path of inclusion.
 2734
 2735        If `atLevel` is not `None`, then only zones at that hierarchy
 2736        level will be included.
 2737
 2738        Raises a `MissingDecisionError` if the target is nether a valid
 2739        zone nor a valid decision.
 2740
 2741        Example:
 2742
 2743        >>> g = DecisionGraph()
 2744        >>> g.addDecision('A')
 2745        0
 2746        >>> g.addDecision('B')
 2747        1
 2748        >>> g.createZone('level0', 0)
 2749        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2750 annotations=[])
 2751        >>> g.createZone('level1', 1)
 2752        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2753 annotations=[])
 2754        >>> g.createZone('level2', 2)
 2755        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2756 annotations=[])
 2757        >>> g.createZone('level3', 3)
 2758        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2759 annotations=[])
 2760        >>> g.addDecisionToZone('A', 'level0')
 2761        >>> g.addDecisionToZone('B', 'level0')
 2762        >>> g.addZoneToZone('level0', 'level1')
 2763        >>> g.addZoneToZone('level1', 'level2')
 2764        >>> g.addZoneToZone('level2', 'level3')
 2765        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 2766        >>> sorted(g.zoneAncestors(0))
 2767        ['level0', 'level1', 'level2', 'level3']
 2768        >>> sorted(g.zoneAncestors(1))
 2769        ['level0', 'level1', 'level2', 'level3']
 2770        >>> sorted(g.zoneParents(0))
 2771        ['level0']
 2772        >>> sorted(g.zoneParents(1))
 2773        ['level0', 'level2']
 2774        >>> sorted(g.zoneAncestors(0, atLevel=2))
 2775        ['level2']
 2776        >>> sorted(g.zoneAncestors(0, exclude={'level2'}))
 2777        ['level0', 'level1']
 2778        """
 2779        # Copy is important here!
 2780        result = set(self.zoneParents(zoneOrDecision))
 2781        result -= exclude
 2782        for parent in copy.copy(result):
 2783            # Recursively dig up ancestors, but exclude
 2784            # results-so-far to avoid re-enumerating when there are
 2785            # multiple braided inclusion paths.
 2786            result |= self.zoneAncestors(parent, result | exclude, atLevel)
 2787
 2788        if atLevel is not None:
 2789            return {z for z in result if self.zoneHierarchyLevel(z) == atLevel}
 2790        else:
 2791            return result
 2792
 2793    def region(
 2794        self,
 2795        decision: base.DecisionID,
 2796        useLevel: int=1
 2797    ) -> Optional[base.Zone]:
 2798        """
 2799        Returns the 'region' that this decision belongs to. 'Regions'
 2800        are level-1 zones, but when a decision is in multiple level-1
 2801        zones, its region counts as the smallest of those zones in terms
 2802        of total decisions contained, breaking ties by the one with the
 2803        alphabetically earlier name.
 2804
 2805        Always returns a single zone name string, unless the target
 2806        decision is not in any level-1 zones, in which case it returns
 2807        `None`.
 2808
 2809        If `useLevel` is specified, then zones of the specified level
 2810        will be used instead of level-1 zones.
 2811
 2812        Example:
 2813
 2814        >>> g = DecisionGraph()
 2815        >>> g.addDecision('A')
 2816        0
 2817        >>> g.addDecision('B')
 2818        1
 2819        >>> g.addDecision('C')
 2820        2
 2821        >>> g.addDecision('D')
 2822        3
 2823        >>> g.createZone('zoneX', 0)
 2824        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2825 annotations=[])
 2826        >>> g.createZone('regionA', 1)
 2827        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2828 annotations=[])
 2829        >>> g.createZone('zoneY', 0)
 2830        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2831 annotations=[])
 2832        >>> g.createZone('regionB', 1)
 2833        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2834 annotations=[])
 2835        >>> g.createZone('regionC', 1)
 2836        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2837 annotations=[])
 2838        >>> g.createZone('quadrant', 2)
 2839        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2840 annotations=[])
 2841        >>> g.addDecisionToZone('A', 'zoneX')
 2842        >>> g.addDecisionToZone('B', 'zoneY')
 2843        >>> # C is not in any level-1 zones
 2844        >>> g.addDecisionToZone('D', 'zoneX')
 2845        >>> g.addDecisionToZone('D', 'zoneY')  # D is in both
 2846        >>> g.addZoneToZone('zoneX', 'regionA')
 2847        >>> g.addZoneToZone('zoneY', 'regionB')
 2848        >>> g.addZoneToZone('zoneX', 'regionC')  # includes both
 2849        >>> g.addZoneToZone('zoneY', 'regionC')
 2850        >>> g.addZoneToZone('regionA', 'quadrant')
 2851        >>> g.addZoneToZone('regionB', 'quadrant')
 2852        >>> g.addDecisionToZone('C', 'regionC')  # Direct in level-2
 2853        >>> sorted(g.allDecisionsInZone('zoneX'))
 2854        [0, 3]
 2855        >>> sorted(g.allDecisionsInZone('zoneY'))
 2856        [1, 3]
 2857        >>> sorted(g.allDecisionsInZone('regionA'))
 2858        [0, 3]
 2859        >>> sorted(g.allDecisionsInZone('regionB'))
 2860        [1, 3]
 2861        >>> sorted(g.allDecisionsInZone('regionC'))
 2862        [0, 1, 2, 3]
 2863        >>> sorted(g.allDecisionsInZone('quadrant'))
 2864        [0, 1, 3]
 2865        >>> g.region(0)  # for A; region A is smaller than region C
 2866        'regionA'
 2867        >>> g.region(1)  # for B; region B is also smaller than C
 2868        'regionB'
 2869        >>> g.region(2)  # for C
 2870        'regionC'
 2871        >>> g.region(3)  # for D; tie broken alphabetically
 2872        'regionA'
 2873        >>> g.region(0, useLevel=0)  # for A at level 0
 2874        'zoneX'
 2875        >>> g.region(1, useLevel=0)  # for B at level 0
 2876        'zoneY'
 2877        >>> g.region(2, useLevel=0) is None  # for C at level 0 (none)
 2878        True
 2879        >>> g.region(3, useLevel=0)  # for D at level 0; tie
 2880        'zoneX'
 2881        >>> g.region(0, useLevel=2) # for A at level 2
 2882        'quadrant'
 2883        >>> g.region(1, useLevel=2) # for B at level 2
 2884        'quadrant'
 2885        >>> g.region(2, useLevel=2) is None # for C at level 2 (none)
 2886        True
 2887        >>> g.region(3, useLevel=2)  # for D at level 2
 2888        'quadrant'
 2889        """
 2890        relevant = self.zoneAncestors(decision, atLevel=useLevel)
 2891        if len(relevant) == 0:
 2892            return None
 2893        elif len(relevant) == 1:
 2894            for zone in relevant:
 2895                return zone
 2896            return None  # not really necessary but keeps mypy happy
 2897        else:
 2898            # more than one zone ancestor at the relevant hierarchy
 2899            # level: need to measure their sizes
 2900            minSize = None
 2901            candidates = []
 2902            for zone in relevant:
 2903                size = len(self.allDecisionsInZone(zone))
 2904                if minSize is None or size < minSize:
 2905                    candidates = [zone]
 2906                    minSize = size
 2907                elif size == minSize:
 2908                    candidates.append(zone)
 2909            return min(candidates)
 2910
 2911    def zoneEdges(self, zone: base.Zone) -> Optional[
 2912        Tuple[
 2913            Set[Tuple[base.DecisionID, base.Transition]],
 2914            Set[Tuple[base.DecisionID, base.Transition]]
 2915        ]
 2916    ]:
 2917        """
 2918        Given a zone to look at, finds all of the transitions which go
 2919        out of and into that zone, ignoring internal transitions between
 2920        decisions in the zone. This includes all decisions in sub-zones.
 2921        The return value is a pair of sets for outgoing and then
 2922        incoming transitions, where each transition is specified as a
 2923        (sourceID, transitionName) pair.
 2924
 2925        Returns `None` if the target zone isn't yet fully defined.
 2926
 2927        Note that this takes time proportional to *all* edges plus *all*
 2928        nodes in the graph no matter how large or small the zone in
 2929        question is.
 2930
 2931        >>> g = DecisionGraph()
 2932        >>> g.addDecision('A')
 2933        0
 2934        >>> g.addDecision('B')
 2935        1
 2936        >>> g.addDecision('C')
 2937        2
 2938        >>> g.addDecision('D')
 2939        3
 2940        >>> g.addTransition('A', 'up', 'B', 'down')
 2941        >>> g.addTransition('B', 'right', 'C', 'left')
 2942        >>> g.addTransition('C', 'down', 'D', 'up')
 2943        >>> g.addTransition('D', 'left', 'A', 'right')
 2944        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
 2945        >>> g.createZone('Z', 0)
 2946        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2947 annotations=[])
 2948        >>> g.createZone('ZZ', 1)
 2949        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2950 annotations=[])
 2951        >>> g.addZoneToZone('Z', 'ZZ')
 2952        >>> g.addDecisionToZone('A', 'Z')
 2953        >>> g.addDecisionToZone('B', 'Z')
 2954        >>> g.addDecisionToZone('D', 'ZZ')
 2955        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
 2956        >>> sorted(outgoing)
 2957        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
 2958        >>> sorted(incoming)
 2959        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
 2960        >>> outgoing, incoming = g.zoneEdges('ZZ')
 2961        >>> sorted(outgoing)
 2962        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
 2963        >>> sorted(incoming)
 2964        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
 2965        >>> g.zoneEdges('madeup') is None
 2966        True
 2967        """
 2968        # Find the interior nodes
 2969        try:
 2970            interior = self.allDecisionsInZone(zone)
 2971        except MissingZoneError:
 2972            return None
 2973
 2974        # Set up our result
 2975        results: Tuple[
 2976            Set[Tuple[base.DecisionID, base.Transition]],
 2977            Set[Tuple[base.DecisionID, base.Transition]]
 2978        ] = (set(), set())
 2979
 2980        # Because finding incoming edges requires searching the entire
 2981        # graph anyways, it's more efficient to just consider each edge
 2982        # once.
 2983        for fromDecision in self:
 2984            fromThere = self[fromDecision]
 2985            for toDecision in fromThere:
 2986                for transition in fromThere[toDecision]:
 2987                    sourceIn = fromDecision in interior
 2988                    destIn = toDecision in interior
 2989                    if sourceIn and not destIn:
 2990                        results[0].add((fromDecision, transition))
 2991                    elif destIn and not sourceIn:
 2992                        results[1].add((fromDecision, transition))
 2993
 2994        return results
 2995
 2996    def replaceZonesInHierarchy(
 2997        self,
 2998        target: base.AnyDecisionSpecifier,
 2999        zone: base.Zone,
 3000        level: int
 3001    ) -> None:
 3002        """
 3003        This method replaces one or more zones which contain the
 3004        specified `target` decision with a specific zone, at a specific
 3005        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
 3006        named zone doesn't yet exist, it will be created.
 3007
 3008        To do this, it looks at all zones which contain the target
 3009        decision directly or indirectly (see `zoneAncestors`) and which
 3010        are at the specified level.
 3011
 3012        - Any direct children of those zones which are ancestors of the
 3013            target decision are removed from those zones and placed into
 3014            the new zone instead, regardless of their levels. Indirect
 3015            children are not affected (except perhaps indirectly via
 3016            their parents' ancestors changing).
 3017        - The new zone is placed into every direct parent of those
 3018            zones, regardless of their levels (those parents are by
 3019            definition all ancestors of the target decision).
 3020        - If there were no zones at the target level, every zone at the
 3021            next level down which is an ancestor of the target decision
 3022            (or just that decision if the level is 0) is placed into the
 3023            new zone as a direct child (and is removed from any previous
 3024            parents it had). In this case, the new zone will also be
 3025            added as a sub-zone to every ancestor of the target decision
 3026            at the level above the specified level, if there are any.
 3027            * In this case, if there are no zones at the level below the
 3028                specified level, the highest level of zones smaller than
 3029                that is treated as the level below, down to targeting
 3030                the decision itself.
 3031            * Similarly, if there are no zones at the level above the
 3032                specified level but there are zones at a higher level,
 3033                the new zone will be added to each of the zones in the
 3034                lowest level above the target level that has zones in it.
 3035
 3036        A `MissingDecisionError` will be raised if the specified
 3037        decision is not valid, or if the decision is left as default but
 3038        there is no current decision in the exploration.
 3039
 3040        An `InvalidLevelError` will be raised if the level is less than
 3041        zero.
 3042
 3043        Example:
 3044
 3045        >>> g = DecisionGraph()
 3046        >>> g.addDecision('decision')
 3047        0
 3048        >>> g.addDecision('alternate')
 3049        1
 3050        >>> g.createZone('zone0', 0)
 3051        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3052 annotations=[])
 3053        >>> g.createZone('zone1', 1)
 3054        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3055 annotations=[])
 3056        >>> g.createZone('zone2.1', 2)
 3057        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3058 annotations=[])
 3059        >>> g.createZone('zone2.2', 2)
 3060        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3061 annotations=[])
 3062        >>> g.createZone('zone3', 3)
 3063        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 3064 annotations=[])
 3065        >>> g.addDecisionToZone('decision', 'zone0')
 3066        >>> g.addDecisionToZone('alternate', 'zone0')
 3067        >>> g.addZoneToZone('zone0', 'zone1')
 3068        >>> g.addZoneToZone('zone1', 'zone2.1')
 3069        >>> g.addZoneToZone('zone1', 'zone2.2')
 3070        >>> g.addZoneToZone('zone2.1', 'zone3')
 3071        >>> g.addZoneToZone('zone2.2', 'zone3')
 3072        >>> g.zoneHierarchyLevel('zone0')
 3073        0
 3074        >>> g.zoneHierarchyLevel('zone1')
 3075        1
 3076        >>> g.zoneHierarchyLevel('zone2.1')
 3077        2
 3078        >>> g.zoneHierarchyLevel('zone2.2')
 3079        2
 3080        >>> g.zoneHierarchyLevel('zone3')
 3081        3
 3082        >>> sorted(g.decisionsInZone('zone0'))
 3083        [0, 1]
 3084        >>> sorted(g.zoneAncestors('zone0'))
 3085        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 3086        >>> g.subZones('zone1')
 3087        {'zone0'}
 3088        >>> g.zoneParents('zone0')
 3089        {'zone1'}
 3090        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
 3091        >>> g.zoneParents('zone0')
 3092        {'zone1'}
 3093        >>> g.zoneParents('new0')
 3094        {'zone1'}
 3095        >>> sorted(g.zoneAncestors('zone0'))
 3096        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 3097        >>> sorted(g.zoneAncestors('new0'))
 3098        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 3099        >>> g.decisionsInZone('zone0')
 3100        {1}
 3101        >>> g.decisionsInZone('new0')
 3102        {0}
 3103        >>> sorted(g.subZones('zone1'))
 3104        ['new0', 'zone0']
 3105        >>> g.zoneParents('new0')
 3106        {'zone1'}
 3107        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
 3108        >>> sorted(g.zoneAncestors(0))
 3109        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
 3110        >>> g.subZones('zone1')
 3111        {'zone0'}
 3112        >>> g.subZones('new1')
 3113        {'new0'}
 3114        >>> g.zoneParents('new0')
 3115        {'new1'}
 3116        >>> sorted(g.zoneParents('zone1'))
 3117        ['zone2.1', 'zone2.2']
 3118        >>> sorted(g.zoneParents('new1'))
 3119        ['zone2.1', 'zone2.2']
 3120        >>> g.zoneParents('zone2.1')
 3121        {'zone3'}
 3122        >>> g.zoneParents('zone2.2')
 3123        {'zone3'}
 3124        >>> sorted(g.subZones('zone2.1'))
 3125        ['new1', 'zone1']
 3126        >>> sorted(g.subZones('zone2.2'))
 3127        ['new1', 'zone1']
 3128        >>> sorted(g.allDecisionsInZone('zone2.1'))
 3129        [0, 1]
 3130        >>> sorted(g.allDecisionsInZone('zone2.2'))
 3131        [0, 1]
 3132        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
 3133        >>> g.zoneParents('zone2.1')
 3134        {'zone3'}
 3135        >>> g.zoneParents('zone2.2')
 3136        {'zone3'}
 3137        >>> g.subZones('zone2.1')
 3138        {'zone1'}
 3139        >>> g.subZones('zone2.2')
 3140        {'zone1'}
 3141        >>> g.subZones('new2')
 3142        {'new1'}
 3143        >>> g.zoneParents('new2')
 3144        {'zone3'}
 3145        >>> g.allDecisionsInZone('zone2.1')
 3146        {1}
 3147        >>> g.allDecisionsInZone('zone2.2')
 3148        {1}
 3149        >>> g.allDecisionsInZone('new2')
 3150        {0}
 3151        >>> sorted(g.subZones('zone3'))
 3152        ['new2', 'zone2.1', 'zone2.2']
 3153        >>> g.zoneParents('zone3')
 3154        set()
 3155        >>> sorted(g.allDecisionsInZone('zone3'))
 3156        [0, 1]
 3157        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
 3158        >>> sorted(g.subZones('zone3'))
 3159        ['zone2.1', 'zone2.2']
 3160        >>> g.subZones('new3')
 3161        {'new2'}
 3162        >>> g.zoneParents('zone3')
 3163        set()
 3164        >>> g.zoneParents('new3')
 3165        set()
 3166        >>> g.allDecisionsInZone('zone3')
 3167        {1}
 3168        >>> g.allDecisionsInZone('new3')
 3169        {0}
 3170        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
 3171        >>> g.subZones('new4')
 3172        {'new3'}
 3173        >>> g.zoneHierarchyLevel('new4')
 3174        5
 3175
 3176        Another example of level collapse when trying to replace a zone
 3177        at a level above :
 3178
 3179        >>> g = DecisionGraph()
 3180        >>> g.addDecision('A')
 3181        0
 3182        >>> g.addDecision('B')
 3183        1
 3184        >>> g.createZone('level0', 0)
 3185        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3186 annotations=[])
 3187        >>> g.createZone('level1', 1)
 3188        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3189 annotations=[])
 3190        >>> g.createZone('level2', 2)
 3191        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3192 annotations=[])
 3193        >>> g.createZone('level3', 3)
 3194        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 3195 annotations=[])
 3196        >>> g.addDecisionToZone('B', 'level0')
 3197        >>> g.addZoneToZone('level0', 'level1')
 3198        >>> g.addZoneToZone('level1', 'level2')
 3199        >>> g.addZoneToZone('level2', 'level3')
 3200        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
 3201        >>> g.zoneHierarchyLevel('level3')
 3202        3
 3203        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
 3204        >>> g.zoneHierarchyLevel('newFirst')
 3205        1
 3206        >>> g.decisionsInZone('newFirst')
 3207        {0}
 3208        >>> g.decisionsInZone('level3')
 3209        set()
 3210        >>> sorted(g.allDecisionsInZone('level3'))
 3211        [0, 1]
 3212        >>> g.subZones('newFirst')
 3213        set()
 3214        >>> sorted(g.subZones('level3'))
 3215        ['level2', 'newFirst']
 3216        >>> g.zoneParents('newFirst')
 3217        {'level3'}
 3218        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
 3219        >>> g.zoneHierarchyLevel('newSecond')
 3220        2
 3221        >>> g.decisionsInZone('newSecond')
 3222        set()
 3223        >>> g.allDecisionsInZone('newSecond')
 3224        {0}
 3225        >>> g.subZones('newSecond')
 3226        {'newFirst'}
 3227        >>> g.zoneParents('newSecond')
 3228        {'level3'}
 3229        >>> g.zoneParents('newFirst')
 3230        {'newSecond'}
 3231        >>> sorted(g.subZones('level3'))
 3232        ['level2', 'newSecond']
 3233        """
 3234        tID = self.resolveDecision(target)
 3235
 3236        if level < 0:
 3237            raise InvalidLevelError(
 3238                f"Target level must be positive (got {level})."
 3239            )
 3240
 3241        info = self.getZoneInfo(zone)
 3242        if info is None:
 3243            info = self.createZone(zone, level)
 3244        elif level != info.level:
 3245            raise InvalidLevelError(
 3246                f"Target level ({level}) does not match the level of"
 3247                f" the target zone ({zone!r} at level {info.level})."
 3248            )
 3249
 3250        # Collect both parents & ancestors
 3251        parents = self.zoneParents(tID)
 3252        ancestors = set(self.zoneAncestors(tID))
 3253
 3254        # Map from levels to sets of zones from the ancestors pool
 3255        levelMap: Dict[int, Set[base.Zone]] = {}
 3256        highest = -1
 3257        for ancestor in ancestors:
 3258            ancestorLevel = self.zoneHierarchyLevel(ancestor)
 3259            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
 3260            if ancestorLevel > highest:
 3261                highest = ancestorLevel
 3262
 3263        # Figure out if we have target zones to replace or not
 3264        reparentDecision = False
 3265        if level in levelMap:
 3266            # If there are zones at the target level,
 3267            targetZones = levelMap[level]
 3268
 3269            above = set()
 3270            below = set()
 3271
 3272            for replaced in targetZones:
 3273                above |= self.zoneParents(replaced)
 3274                below |= self.subZones(replaced)
 3275                if replaced in parents:
 3276                    reparentDecision = True
 3277
 3278            # Only ancestors should be reparented
 3279            below &= ancestors
 3280
 3281        else:
 3282            # Find levels w/ zones in them above + below
 3283            levelBelow = level - 1
 3284            levelAbove = level + 1
 3285            below = levelMap.get(levelBelow, set())
 3286            above = levelMap.get(levelAbove, set())
 3287
 3288            while len(below) == 0 and levelBelow > 0:
 3289                levelBelow -= 1
 3290                below = levelMap.get(levelBelow, set())
 3291
 3292            if len(below) == 0:
 3293                reparentDecision = True
 3294
 3295            while len(above) == 0 and levelAbove < highest:
 3296                levelAbove += 1
 3297                above = levelMap.get(levelAbove, set())
 3298
 3299        # Handle re-parenting zones below
 3300        for under in below:
 3301            for parent in self.zoneParents(under):
 3302                if parent in ancestors:
 3303                    self.removeZoneFromZone(under, parent)
 3304            self.addZoneToZone(under, zone)
 3305
 3306        # Add this zone to each parent
 3307        for parent in above:
 3308            self.addZoneToZone(zone, parent)
 3309
 3310        # Re-parent the decision itself if necessary
 3311        if reparentDecision:
 3312            # (using set() here to avoid size-change-during-iteration)
 3313            for parent in set(parents):
 3314                self.removeDecisionFromZone(tID, parent)
 3315            self.addDecisionToZone(tID, zone)
 3316
 3317    def getReciprocal(
 3318        self,
 3319        decision: base.AnyDecisionSpecifier,
 3320        transition: base.Transition
 3321    ) -> Optional[base.Transition]:
 3322        """
 3323        Returns the reciprocal edge for the specified transition from the
 3324        specified decision (see `setReciprocal`). Returns
 3325        `None` if no reciprocal has been established for that
 3326        transition, or if that decision or transition does not exist.
 3327        """
 3328        dID = self.resolveDecision(decision)
 3329
 3330        dest = self.getDestination(dID, transition)
 3331        if dest is not None:
 3332            info = cast(
 3333                TransitionProperties,
 3334                self.edges[dID, dest, transition]  # type:ignore
 3335            )
 3336            recip = info.get("reciprocal")
 3337            if recip is not None and not isinstance(recip, base.Transition):
 3338                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
 3339            return recip
 3340        else:
 3341            return None
 3342
 3343    def setReciprocal(
 3344        self,
 3345        decision: base.AnyDecisionSpecifier,
 3346        transition: base.Transition,
 3347        reciprocal: Optional[base.Transition],
 3348        setBoth: bool = True,
 3349        cleanup: bool = True
 3350    ) -> None:
 3351        """
 3352        Sets the 'reciprocal' transition for a particular transition from
 3353        a particular decision, and removes the reciprocal property from
 3354        any old reciprocal transition.
 3355
 3356        Raises a `MissingDecisionError` or a `MissingTransitionError` if
 3357        the specified decision or transition does not exist.
 3358
 3359        Raises an `InvalidDestinationError` if the reciprocal transition
 3360        does not exist, or if it does exist but does not lead back to
 3361        the decision the transition came from.
 3362
 3363        If `setBoth` is True (the default) then the transition which is
 3364        being identified as a reciprocal will also have its reciprocal
 3365        property set, pointing back to the primary transition being
 3366        modified, and any old reciprocal of that transition will have its
 3367        reciprocal set to None. If you want to create a situation with
 3368        non-exclusive reciprocals, use `setBoth=False`.
 3369
 3370        If `cleanup` is True (the default) then abandoned reciprocal
 3371        transitions (for both edges if `setBoth` was true) have their
 3372        reciprocal properties removed. Set `cleanup` to false if you want
 3373        to retain them, although this will result in non-exclusive
 3374        reciprocal relationships.
 3375
 3376        If the `reciprocal` value is None, this deletes the reciprocal
 3377        value entirely, and if `setBoth` is true, it does this for the
 3378        previous reciprocal edge as well. No error is raised in this case
 3379        when there was not already a reciprocal to delete.
 3380
 3381        Note that one should remove a reciprocal relationship before
 3382        redirecting either edge of the pair in a way that gives it a new
 3383        reciprocal, since otherwise, a later attempt to remove the
 3384        reciprocal with `setBoth` set to True (the default) will end up
 3385        deleting the reciprocal information from the other edge that was
 3386        already modified. There is no way to reliably detect and avoid
 3387        this, because two different decisions could (and often do in
 3388        practice) have transitions with identical names, meaning that the
 3389        reciprocal value will still be the same, but it will indicate a
 3390        different edge in virtue of the destination of the edge changing.
 3391
 3392        ## Example
 3393
 3394        >>> g = DecisionGraph()
 3395        >>> g.addDecision('G')
 3396        0
 3397        >>> g.addDecision('H')
 3398        1
 3399        >>> g.addDecision('I')
 3400        2
 3401        >>> g.addTransition('G', 'up', 'H', 'down')
 3402        >>> g.addTransition('G', 'next', 'H', 'prev')
 3403        >>> g.addTransition('H', 'next', 'I', 'prev')
 3404        >>> g.addTransition('H', 'return', 'G')
 3405        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
 3406        Traceback (most recent call last):
 3407        ...
 3408        exploration.core.InvalidDestinationError...
 3409        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
 3410        Traceback (most recent call last):
 3411        ...
 3412        exploration.core.MissingTransitionError...
 3413        >>> g.getReciprocal('G', 'up')
 3414        'down'
 3415        >>> g.getReciprocal('H', 'down')
 3416        'up'
 3417        >>> g.getReciprocal('H', 'return') is None
 3418        True
 3419        >>> g.setReciprocal('G', 'up', 'return')
 3420        >>> g.getReciprocal('G', 'up')
 3421        'return'
 3422        >>> g.getReciprocal('H', 'down') is None
 3423        True
 3424        >>> g.getReciprocal('H', 'return')
 3425        'up'
 3426        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
 3427        >>> g.getReciprocal('G', 'up') is None
 3428        True
 3429        >>> g.getReciprocal('H', 'down') is None
 3430        True
 3431        >>> g.getReciprocal('H', 'return') is None
 3432        True
 3433        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
 3434        >>> g.getReciprocal('G', 'up')
 3435        'down'
 3436        >>> g.getReciprocal('H', 'down') is None
 3437        True
 3438        >>> g.getReciprocal('H', 'return') is None
 3439        True
 3440        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
 3441        >>> g.getReciprocal('G', 'up')
 3442        'down'
 3443        >>> g.getReciprocal('H', 'down') is None
 3444        True
 3445        >>> g.getReciprocal('H', 'return')
 3446        'up'
 3447        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
 3448        >>> g.getReciprocal('G', 'up')
 3449        'down'
 3450        >>> g.getReciprocal('H', 'down')
 3451        'up'
 3452        >>> g.getReciprocal('H', 'return') # unchanged
 3453        'up'
 3454        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
 3455        >>> g.getReciprocal('G', 'up')
 3456        'return'
 3457        >>> g.getReciprocal('H', 'down')
 3458        'up'
 3459        >>> g.getReciprocal('H', 'return') # unchanged
 3460        'up'
 3461        >>> # Cleanup only applies to reciprocal if setBoth is true
 3462        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
 3463        >>> g.getReciprocal('G', 'up')
 3464        'return'
 3465        >>> g.getReciprocal('H', 'down')
 3466        'up'
 3467        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
 3468        'up'
 3469        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
 3470        >>> g.getReciprocal('G', 'up')
 3471        'down'
 3472        >>> g.getReciprocal('H', 'down')
 3473        'up'
 3474        >>> g.getReciprocal('H', 'return') is None # cleaned up
 3475        True
 3476        """
 3477        dID = self.resolveDecision(decision)
 3478
 3479        dest = self.destination(dID, transition) # possible KeyError
 3480        if reciprocal is None:
 3481            rDest = None
 3482        else:
 3483            rDest = self.getDestination(dest, reciprocal)
 3484
 3485        # Set or delete reciprocal property
 3486        if reciprocal is None:
 3487            # Delete the property
 3488            info = self.edges[dID, dest, transition]  # type:ignore
 3489
 3490            old = info.pop('reciprocal')
 3491            if setBoth:
 3492                rDest = self.getDestination(dest, old)
 3493                if rDest != dID:
 3494                    raise RuntimeError(
 3495                        f"Invalid reciprocal {old!r} for transition"
 3496                        f" {transition!r} from {self.identityOf(dID)}:"
 3497                        f" destination is {rDest}."
 3498                    )
 3499                rInfo = self.edges[dest, dID, old]  # type:ignore
 3500                if 'reciprocal' in rInfo:
 3501                    del rInfo['reciprocal']
 3502        else:
 3503            # Set the property, checking for errors first
 3504            if rDest is None:
 3505                raise MissingTransitionError(
 3506                    f"Reciprocal transition {reciprocal!r} for"
 3507                    f" transition {transition!r} from decision"
 3508                    f" {self.identityOf(dID)} does not exist at"
 3509                    f" decision {self.identityOf(dest)}"
 3510                )
 3511
 3512            if rDest != dID:
 3513                raise InvalidDestinationError(
 3514                    f"Reciprocal transition {reciprocal!r} from"
 3515                    f" decision {self.identityOf(dest)} does not lead"
 3516                    f" back to decision {self.identityOf(dID)}."
 3517                )
 3518
 3519            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
 3520            abandoned = eProps.get('reciprocal')
 3521            eProps['reciprocal'] = reciprocal
 3522            if cleanup and abandoned not in (None, reciprocal):
 3523                aProps = self.edges[dest, dID, abandoned]  # type:ignore
 3524                if 'reciprocal' in aProps:
 3525                    del aProps['reciprocal']
 3526
 3527            if setBoth:
 3528                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
 3529                revAbandoned = rProps.get('reciprocal')
 3530                rProps['reciprocal'] = transition
 3531                # Sever old reciprocal relationship
 3532                if cleanup and revAbandoned not in (None, transition):
 3533                    raProps = self.edges[
 3534                        dID,  # type:ignore
 3535                        dest,
 3536                        revAbandoned
 3537                    ]
 3538                    del raProps['reciprocal']
 3539
 3540    def getReciprocalPair(
 3541        self,
 3542        decision: base.AnyDecisionSpecifier,
 3543        transition: base.Transition
 3544    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
 3545        """
 3546        Returns a tuple containing both the destination decision ID and
 3547        the transition at that decision which is the reciprocal of the
 3548        specified destination & transition. Returns `None` if no
 3549        reciprocal has been established for that transition, or if that
 3550        decision or transition does not exist.
 3551
 3552        >>> g = DecisionGraph()
 3553        >>> g.addDecision('A')
 3554        0
 3555        >>> g.addDecision('B')
 3556        1
 3557        >>> g.addDecision('C')
 3558        2
 3559        >>> g.addTransition('A', 'up', 'B', 'down')
 3560        >>> g.addTransition('B', 'right', 'C', 'left')
 3561        >>> g.addTransition('A', 'oneway', 'C')
 3562        >>> g.getReciprocalPair('A', 'up')
 3563        (1, 'down')
 3564        >>> g.getReciprocalPair('B', 'down')
 3565        (0, 'up')
 3566        >>> g.getReciprocalPair('B', 'right')
 3567        (2, 'left')
 3568        >>> g.getReciprocalPair('C', 'left')
 3569        (1, 'right')
 3570        >>> g.getReciprocalPair('C', 'up') is None
 3571        True
 3572        >>> g.getReciprocalPair('Q', 'up') is None
 3573        True
 3574        >>> g.getReciprocalPair('A', 'tunnel') is None
 3575        True
 3576        """
 3577        try:
 3578            dID = self.resolveDecision(decision)
 3579        except MissingDecisionError:
 3580            return None
 3581
 3582        reciprocal = self.getReciprocal(dID, transition)
 3583        if reciprocal is None:
 3584            return None
 3585        else:
 3586            destination = self.getDestination(dID, transition)
 3587            if destination is None:
 3588                return None
 3589            else:
 3590                return (destination, reciprocal)
 3591
 3592    def addDecision(
 3593        self,
 3594        name: base.DecisionName,
 3595        domain: Optional[base.Domain] = None,
 3596        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3597        annotations: Optional[List[base.Annotation]] = None
 3598    ) -> base.DecisionID:
 3599        """
 3600        Adds a decision to the graph, without any transitions yet. Each
 3601        decision will be assigned an ID so name collisions are allowed,
 3602        but it's usually best to keep names unique at least within each
 3603        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
 3604        used for the decision's domain. A dictionary of tags and/or a
 3605        list of annotations (strings in both cases) may be provided.
 3606
 3607        Returns the newly-assigned `DecisionID` for the decision it
 3608        created.
 3609
 3610        Emits a `DecisionCollisionWarning` if a decision with the
 3611        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
 3612        global variable is set to `True`.
 3613        """
 3614        # Defaults
 3615        if domain is None:
 3616            domain = base.DEFAULT_DOMAIN
 3617        if tags is None:
 3618            tags = {}
 3619        if annotations is None:
 3620            annotations = []
 3621
 3622        # Error checking
 3623        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 3624            warnings.warn(
 3625                (
 3626                    f"Adding decision {name!r}: Another decision with"
 3627                    f" that name already exists."
 3628                ),
 3629                DecisionCollisionWarning
 3630            )
 3631
 3632        dID = self._assignID()
 3633
 3634        # Add the decision
 3635        self.add_node(
 3636            dID,
 3637            name=name,
 3638            domain=domain,
 3639            tags=tags,
 3640            annotations=annotations
 3641        )
 3642        #TODO: Elide tags/annotations if they're empty?
 3643
 3644        # Track it in our `nameLookup` dictionary
 3645        self.nameLookup.setdefault(name, []).append(dID)
 3646
 3647        return dID
 3648
 3649    def addIdentifiedDecision(
 3650        self,
 3651        dID: base.DecisionID,
 3652        name: base.DecisionName,
 3653        domain: Optional[base.Domain] = None,
 3654        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3655        annotations: Optional[List[base.Annotation]] = None
 3656    ) -> None:
 3657        """
 3658        Adds a new decision to the graph using a specific decision ID,
 3659        rather than automatically assigning a new decision ID like
 3660        `addDecision` does. Otherwise works like `addDecision`.
 3661
 3662        Raises a `MechanismCollisionError` if the specified decision ID
 3663        is already in use.
 3664        """
 3665        # Defaults
 3666        if domain is None:
 3667            domain = base.DEFAULT_DOMAIN
 3668        if tags is None:
 3669            tags = {}
 3670        if annotations is None:
 3671            annotations = []
 3672
 3673        # Error checking
 3674        if dID in self.nodes:
 3675            raise MechanismCollisionError(
 3676                f"Cannot add a node with id {dID} and name {name!r}:"
 3677                f" that ID is already used by node {self.identityOf(dID)}"
 3678            )
 3679
 3680        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 3681            warnings.warn(
 3682                (
 3683                    f"Adding decision {name!r}: Another decision with"
 3684                    f" that name already exists."
 3685                ),
 3686                DecisionCollisionWarning
 3687            )
 3688
 3689        # Add the decision
 3690        self.add_node(
 3691            dID,
 3692            name=name,
 3693            domain=domain,
 3694            tags=tags,
 3695            annotations=annotations
 3696        )
 3697        #TODO: Elide tags/annotations if they're empty?
 3698
 3699        # Track it in our `nameLookup` dictionary
 3700        self.nameLookup.setdefault(name, []).append(dID)
 3701
 3702    def addTransition(
 3703        self,
 3704        fromDecision: base.AnyDecisionSpecifier,
 3705        name: base.Transition,
 3706        toDecision: base.AnyDecisionSpecifier,
 3707        reciprocal: Optional[base.Transition] = None,
 3708        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3709        annotations: Optional[List[base.Annotation]] = None,
 3710        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3711        revAnnotations: Optional[List[base.Annotation]] = None,
 3712        requires: Optional[base.Requirement] = None,
 3713        consequence: Optional[base.Consequence] = None,
 3714        revRequires: Optional[base.Requirement] = None,
 3715        revConsequece: Optional[base.Consequence] = None
 3716    ) -> None:
 3717        """
 3718        Adds a transition connecting two decisions. A specifier for each
 3719        decision is required, as is a name for the transition. If a
 3720        `reciprocal` is provided, a reciprocal edge will be added in the
 3721        opposite direction using that name; by default only the specified
 3722        edge is added. A `TransitionCollisionError` will be raised if the
 3723        `reciprocal` matches the name of an existing edge at the
 3724        destination decision.
 3725
 3726        Both decisions must already exist, or a `MissingDecisionError`
 3727        will be raised.
 3728
 3729        A dictionary of tags and/or a list of annotations may be
 3730        provided. Tags and/or annotations for the reverse edge may also
 3731        be specified if one is being added.
 3732
 3733        The `requires`, `consequence`, `revRequires`, and `revConsequece`
 3734        arguments specify requirements and/or consequences of the new
 3735        outgoing and reciprocal edges.
 3736        """
 3737        # Defaults
 3738        if tags is None:
 3739            tags = {}
 3740        if annotations is None:
 3741            annotations = []
 3742        if revTags is None:
 3743            revTags = {}
 3744        if revAnnotations is None:
 3745            revAnnotations = []
 3746
 3747        # Error checking
 3748        fromID = self.resolveDecision(fromDecision)
 3749        toID = self.resolveDecision(toDecision)
 3750
 3751        # Note: have to check this first so we don't add the forward edge
 3752        # and then error out after a side effect!
 3753        if (
 3754            reciprocal is not None
 3755        and self.getDestination(toDecision, reciprocal) is not None
 3756        ):
 3757            raise TransitionCollisionError(
 3758                f"Cannot add a transition from"
 3759                f" {self.identityOf(fromDecision)} to"
 3760                f" {self.identityOf(toDecision)} with reciprocal edge"
 3761                f" {reciprocal!r}: {reciprocal!r} is already used as an"
 3762                f" edge name at {self.identityOf(toDecision)}."
 3763            )
 3764
 3765        # Add the edge
 3766        self.add_edge(
 3767            fromID,
 3768            toID,
 3769            key=name,
 3770            tags=tags,
 3771            annotations=annotations
 3772        )
 3773        self.setTransitionRequirement(fromDecision, name, requires)
 3774        if consequence is not None:
 3775            self.setConsequence(fromDecision, name, consequence)
 3776        if reciprocal is not None:
 3777            # Add the reciprocal edge
 3778            self.add_edge(
 3779                toID,
 3780                fromID,
 3781                key=reciprocal,
 3782                tags=revTags,
 3783                annotations=revAnnotations
 3784            )
 3785            self.setReciprocal(fromID, name, reciprocal)
 3786            self.setTransitionRequirement(
 3787                toDecision,
 3788                reciprocal,
 3789                revRequires
 3790            )
 3791            if revConsequece is not None:
 3792                self.setConsequence(toDecision, reciprocal, revConsequece)
 3793
 3794    def removeTransition(
 3795        self,
 3796        fromDecision: base.AnyDecisionSpecifier,
 3797        transition: base.Transition,
 3798        removeReciprocal=False
 3799    ) -> Union[
 3800        TransitionProperties,
 3801        Tuple[TransitionProperties, TransitionProperties]
 3802    ]:
 3803        """
 3804        Removes a transition. If `removeReciprocal` is true (False is the
 3805        default) any reciprocal transition will also be removed (but no
 3806        error will occur if there wasn't a reciprocal).
 3807
 3808        For each removed transition, *every* transition that targeted
 3809        that transition as its reciprocal will have its reciprocal set to
 3810        `None`, to avoid leaving any invalid reciprocal values.
 3811
 3812        Raises a `KeyError` if either the target decision or the target
 3813        transition does not exist.
 3814
 3815        Returns a transition properties dictionary with the properties
 3816        of the removed transition, or if `removeReciprocal` is true,
 3817        returns a pair of such dictionaries for the target transition
 3818        and its reciprocal.
 3819
 3820        ## Example
 3821
 3822        >>> g = DecisionGraph()
 3823        >>> g.addDecision('A')
 3824        0
 3825        >>> g.addDecision('B')
 3826        1
 3827        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
 3828        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
 3829        >>> g.addTransition('A', 'next', 'B')
 3830        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
 3831        >>> p = g.removeTransition('A', 'up')
 3832        >>> p['tags']
 3833        {'wide'}
 3834        >>> g.destinationsFrom('A')
 3835        {'in': 1, 'next': 1}
 3836        >>> g.destinationsFrom('B')
 3837        {'down': 0, 'out': 0}
 3838        >>> g.getReciprocal('B', 'down') is None
 3839        True
 3840        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
 3841        'down'
 3842        >>> g.getReciprocal('A', 'in') # not affected
 3843        'out'
 3844        >>> g.getReciprocal('B', 'out') # not affected
 3845        'in'
 3846        >>> # Now with removeReciprocal set to True
 3847        >>> g.addTransition('A', 'up', 'B') # add this back in
 3848        >>> g.setReciprocal('A', 'up', 'down') # sets both
 3849        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
 3850        >>> g.destinationsFrom('A')
 3851        {'in': 1, 'next': 1}
 3852        >>> g.destinationsFrom('B')
 3853        {'out': 0}
 3854        >>> g.getReciprocal('A', 'next') is None
 3855        True
 3856        >>> g.getReciprocal('A', 'in') # not affected
 3857        'out'
 3858        >>> g.getReciprocal('B', 'out') # not affected
 3859        'in'
 3860        >>> g.removeTransition('A', 'none')
 3861        Traceback (most recent call last):
 3862        ...
 3863        exploration.core.MissingTransitionError...
 3864        >>> g.removeTransition('Z', 'nope')
 3865        Traceback (most recent call last):
 3866        ...
 3867        exploration.core.MissingDecisionError...
 3868        """
 3869        # Resolve target ID
 3870        fromID = self.resolveDecision(fromDecision)
 3871
 3872        # raises if either is missing:
 3873        destination = self.destination(fromID, transition)
 3874        reciprocal = self.getReciprocal(fromID, transition)
 3875
 3876        # Get dictionaries of parallel & antiparallel edges to be
 3877        # checked for invalid reciprocals after removing edges
 3878        # Note: these will update live as we remove edges
 3879        allAntiparallel = self[destination][fromID]
 3880        allParallel = self[fromID][destination]
 3881
 3882        # Remove the target edge
 3883        fProps = self.getTransitionProperties(fromID, transition)
 3884        self.remove_edge(fromID, destination, transition)
 3885
 3886        # Clean up any dangling reciprocal values
 3887        for tProps in allAntiparallel.values():
 3888            if tProps.get('reciprocal') == transition:
 3889                del tProps['reciprocal']
 3890
 3891        # Remove the reciprocal if requested
 3892        if removeReciprocal and reciprocal is not None:
 3893            rProps = self.getTransitionProperties(destination, reciprocal)
 3894            self.remove_edge(destination, fromID, reciprocal)
 3895
 3896            # Clean up any dangling reciprocal values
 3897            for tProps in allParallel.values():
 3898                if tProps.get('reciprocal') == reciprocal:
 3899                    del tProps['reciprocal']
 3900
 3901            return (fProps, rProps)
 3902        else:
 3903            return fProps
 3904
 3905    def addMechanism(
 3906        self,
 3907        name: base.MechanismName,
 3908        where: Optional[base.AnyDecisionSpecifier] = None
 3909    ) -> base.MechanismID:
 3910        """
 3911        Creates a new mechanism with the given name at the specified
 3912        decision, returning its assigned ID. If `where` is `None`, it
 3913        creates a global mechanism. Raises a `MechanismCollisionError`
 3914        if a mechanism with the same name already exists at a specified
 3915        decision (or already exists as a global mechanism).
 3916
 3917        Note that if the decision is deleted, the mechanism will be as
 3918        well.
 3919
 3920        Since `MechanismState`s are not tracked by `DecisionGraph`s but
 3921        instead are part of a `State`, the mechanism won't be in any
 3922        particular state, which means it will be treated as being in the
 3923        `base.DEFAULT_MECHANISM_STATE`.
 3924        """
 3925        if where is None:
 3926            mechs = self.globalMechanisms
 3927            dID = None
 3928        else:
 3929            dID = self.resolveDecision(where)
 3930            mechs = self.nodes[dID].setdefault('mechanisms', {})
 3931
 3932        if name in mechs:
 3933            if dID is None:
 3934                raise MechanismCollisionError(
 3935                    f"A global mechanism named {name!r} already exists."
 3936                )
 3937            else:
 3938                raise MechanismCollisionError(
 3939                    f"A mechanism named {name!r} already exists at"
 3940                    f" decision {self.identityOf(dID)}."
 3941                )
 3942
 3943        mID = self._assignMechanismID()
 3944        mechs[name] = mID
 3945        self.mechanisms[mID] = (dID, name)
 3946        return mID
 3947
 3948    def mechanismsAt(
 3949        self,
 3950        decision: base.AnyDecisionSpecifier
 3951    ) -> Dict[base.MechanismName, base.MechanismID]:
 3952        """
 3953        Returns a dictionary mapping mechanism names to their IDs for
 3954        all mechanisms at the specified decision.
 3955        """
 3956        dID = self.resolveDecision(decision)
 3957
 3958        return self.nodes[dID]['mechanisms']
 3959
 3960    def mechanismDetails(
 3961        self,
 3962        mID: base.MechanismID
 3963    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
 3964        """
 3965        Returns a tuple containing the decision ID and mechanism name
 3966        for the specified mechanism. Returns `None` if there is no
 3967        mechanism with that ID. For global mechanisms, `None` is used in
 3968        place of a decision ID.
 3969        """
 3970        return self.mechanisms.get(mID)
 3971
 3972    def deleteMechanism(self, mID: base.MechanismID) -> None:
 3973        """
 3974        Deletes the specified mechanism.
 3975        """
 3976        name, dID = self.mechanisms.pop(mID)
 3977
 3978        del self.nodes[dID]['mechanisms'][name]
 3979
 3980    def localLookup(
 3981        self,
 3982        startFrom: Union[
 3983            base.AnyDecisionSpecifier,
 3984            Collection[base.AnyDecisionSpecifier]
 3985        ],
 3986        findAmong: Callable[
 3987            ['DecisionGraph', Union[Set[base.DecisionID], str]],
 3988            Optional[LookupResult]
 3989        ],
 3990        fallbackLayerName: Optional[str] = "fallback",
 3991        fallbackToAllDecisions: bool = True
 3992    ) -> Optional[LookupResult]:
 3993        """
 3994        Looks up some kind of result in the graph by starting from a
 3995        base set of decisions and widening the search iteratively based
 3996        on zones. This first searches for result(s) in the set of
 3997        decisions given, then in the set of all decisions which are in
 3998        level-0 zones containing those decisions, then in level-1 zones,
 3999        etc. When it runs out of relevant zones, it will check all
 4000        decisions which are in any domain that a decision from the
 4001        initial search set is in, and then if `fallbackLayerName` is a
 4002        string, it will provide that string instead of a set of decision
 4003        IDs to the `findAmong` function as the next layer to search.
 4004        After the `fallbackLayerName` is used, if
 4005        `fallbackToAllDecisions` is `True` (the default) a final search
 4006        will be run on all decisions in the graph. The provided
 4007        `findAmong` function is called on each successive decision ID
 4008        set, until it generates a non-`None` result. We stop and return
 4009        that non-`None` result as soon as one is generated. But if none
 4010        of the decision sets consulted generate non-`None` results, then
 4011        the entire result will be `None`.
 4012        """
 4013        # Normalize starting decisions to a set
 4014        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
 4015            startFrom = set([startFrom])
 4016
 4017        # Resolve decision IDs; convert to list
 4018        searchArea: Union[Set[base.DecisionID], str] = set(
 4019            self.resolveDecision(spec) for spec in startFrom
 4020        )
 4021
 4022        # Find all ancestor zones & all relevant domains
 4023        allAncestors = set()
 4024        relevantDomains = set()
 4025        for startingDecision in searchArea:
 4026            allAncestors |= self.zoneAncestors(startingDecision)
 4027            relevantDomains.add(self.domainFor(startingDecision))
 4028
 4029        # Build layers dictionary
 4030        ancestorLayers: Dict[int, Set[base.Zone]] = {}
 4031        for zone in allAncestors:
 4032            info = self.getZoneInfo(zone)
 4033            assert info is not None
 4034            level = info.level
 4035            ancestorLayers.setdefault(level, set()).add(zone)
 4036
 4037        searchLayers: LookupLayersList = (
 4038            cast(LookupLayersList, [None])
 4039          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
 4040          + cast(LookupLayersList, ["domains"])
 4041        )
 4042        if fallbackLayerName is not None:
 4043            searchLayers.append("fallback")
 4044
 4045        if fallbackToAllDecisions:
 4046            searchLayers.append("all")
 4047
 4048        # Continue our search through zone layers
 4049        for layer in searchLayers:
 4050            # Update search area on subsequent iterations
 4051            if layer == "domains":
 4052                searchArea = set()
 4053                for relevant in relevantDomains:
 4054                    searchArea |= self.allDecisionsInDomain(relevant)
 4055            elif layer == "fallback":
 4056                assert fallbackLayerName is not None
 4057                searchArea = fallbackLayerName
 4058            elif layer == "all":
 4059                searchArea = set(self.nodes)
 4060            elif layer is not None:
 4061                layer = cast(int, layer)  # must be an integer
 4062                searchZones = ancestorLayers[layer]
 4063                searchArea = set()
 4064                for zone in searchZones:
 4065                    searchArea |= self.allDecisionsInZone(zone)
 4066            # else it's the first iteration and we use the starting
 4067            # searchArea
 4068
 4069            searchResult: Optional[LookupResult] = findAmong(
 4070                self,
 4071                searchArea
 4072            )
 4073
 4074            if searchResult is not None:
 4075                return searchResult
 4076
 4077        # Didn't find any non-None results.
 4078        return None
 4079
 4080    @staticmethod
 4081    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
 4082        ['DecisionGraph', Union[Set[base.DecisionID], str]],
 4083        Optional[base.MechanismID]
 4084    ]:
 4085        """
 4086        Returns a search function that looks for the given mechanism ID,
 4087        suitable for use with `localLookup`. The finder will raise a
 4088        `MechanismCollisionError` if it finds more than one mechanism
 4089        with the specified name at the same level of the search.
 4090        """
 4091        def namedMechanismFinder(
 4092            graph: 'DecisionGraph',
 4093            searchIn: Union[Set[base.DecisionID], str]
 4094        ) -> Optional[base.MechanismID]:
 4095            """
 4096            Generated finder function for `localLookup` to find a unique
 4097            mechanism by name.
 4098            """
 4099            candidates: List[base.DecisionID] = []
 4100
 4101            if searchIn == "fallback":
 4102                if name in graph.globalMechanisms:
 4103                    candidates = [graph.globalMechanisms[name]]
 4104
 4105            else:
 4106                assert isinstance(searchIn, set)
 4107                for dID in searchIn:
 4108                    mechs = graph.nodes[dID].get('mechanisms', {})
 4109                    if name in mechs:
 4110                        candidates.append(mechs[name])
 4111
 4112            if len(candidates) > 1:
 4113                raise MechanismCollisionError(
 4114                    f"There are {len(candidates)} mechanisms named {name!r}"
 4115                    f" in the search area ({len(searchIn)} decisions(s))."
 4116                )
 4117            elif len(candidates) == 1:
 4118                return candidates[0]
 4119            else:
 4120                return None
 4121
 4122        return namedMechanismFinder
 4123
 4124    def lookupMechanism(
 4125        self,
 4126        startFrom: Union[
 4127            base.AnyDecisionSpecifier,
 4128            Collection[base.AnyDecisionSpecifier]
 4129        ],
 4130        name: base.MechanismName
 4131    ) -> base.MechanismID:
 4132        """
 4133        Looks up the mechanism with the given name 'closest' to the
 4134        given decision or set of decisions. First it looks for a
 4135        mechanism with that name that's at one of those decisions. Then
 4136        it starts looking in level-0 zones which contain any of them,
 4137        then in level-1 zones, and so on. If it finds two mechanisms
 4138        with the target name during the same search pass, it raises a
 4139        `MechanismCollisionError`, but if it finds one it returns it.
 4140        Raises a `MissingMechanismError` if there is no mechanisms with
 4141        that name among global mechanisms (searched after the last
 4142        applicable level of zones) or anywhere in the graph (which is the
 4143        final level of search after checking global mechanisms).
 4144
 4145        For example:
 4146
 4147        >>> d = DecisionGraph()
 4148        >>> d.addDecision('A')
 4149        0
 4150        >>> d.addDecision('B')
 4151        1
 4152        >>> d.addDecision('C')
 4153        2
 4154        >>> d.addDecision('D')
 4155        3
 4156        >>> d.addDecision('E')
 4157        4
 4158        >>> d.addMechanism('switch', 'A')
 4159        0
 4160        >>> d.addMechanism('switch', 'B')
 4161        1
 4162        >>> d.addMechanism('switch', 'C')
 4163        2
 4164        >>> d.addMechanism('lever', 'D')
 4165        3
 4166        >>> d.addMechanism('lever', None)  # global
 4167        4
 4168        >>> d.createZone('Z1', 0)
 4169        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 4170 annotations=[])
 4171        >>> d.createZone('Z2', 0)
 4172        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 4173 annotations=[])
 4174        >>> d.createZone('Zup', 1)
 4175        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 4176 annotations=[])
 4177        >>> d.addDecisionToZone('A', 'Z1')
 4178        >>> d.addDecisionToZone('B', 'Z1')
 4179        >>> d.addDecisionToZone('C', 'Z2')
 4180        >>> d.addDecisionToZone('D', 'Z2')
 4181        >>> d.addDecisionToZone('E', 'Z1')
 4182        >>> d.addZoneToZone('Z1', 'Zup')
 4183        >>> d.addZoneToZone('Z2', 'Zup')
 4184        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
 4185        Traceback (most recent call last):
 4186        ...
 4187        exploration.core.MechanismCollisionError...
 4188        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
 4189        4
 4190        >>> d.lookupMechanism({'D'}, 'lever')  # local
 4191        3
 4192        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
 4193        3
 4194        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
 4195        3
 4196        >>> d.lookupMechanism({'A'}, 'switch')  # local
 4197        0
 4198        >>> d.lookupMechanism({'B'}, 'switch')  # local
 4199        1
 4200        >>> d.lookupMechanism({'C'}, 'switch')  # local
 4201        2
 4202        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
 4203        Traceback (most recent call last):
 4204        ...
 4205        exploration.core.MechanismCollisionError...
 4206        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
 4207        Traceback (most recent call last):
 4208        ...
 4209        exploration.core.MechanismCollisionError...
 4210        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
 4211        1
 4212        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
 4213        Traceback (most recent call last):
 4214        ...
 4215        exploration.core.MechanismCollisionError...
 4216        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
 4217        Traceback (most recent call last):
 4218        ...
 4219        exploration.core.MechanismCollisionError...
 4220        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
 4221        2
 4222        """
 4223        result = self.localLookup(
 4224            startFrom,
 4225            DecisionGraph.uniqueMechanismFinder(name)
 4226        )
 4227        if result is None:
 4228            raise MissingMechanismError(
 4229                f"No mechanism named {name!r}"
 4230            )
 4231        else:
 4232            return result
 4233
 4234    def resolveMechanism(
 4235        self,
 4236        specifier: base.AnyMechanismSpecifier,
 4237        startFrom: Union[
 4238            None,
 4239            base.AnyDecisionSpecifier,
 4240            Collection[base.AnyDecisionSpecifier]
 4241        ] = None
 4242    ) -> base.MechanismID:
 4243        """
 4244        Works like `lookupMechanism`, except it accepts a
 4245        `base.AnyMechanismSpecifier` which may have position information
 4246        baked in, and so the `startFrom` information is optional. If
 4247        position information isn't specified in the mechanism specifier
 4248        and startFrom is not provided, the mechanism is searched for at
 4249        the global scope and then in the entire graph. On the other
 4250        hand, if the specifier includes any position information, the
 4251        startFrom value provided here will be ignored.
 4252        """
 4253        if isinstance(specifier, base.MechanismID):
 4254            return specifier
 4255
 4256        elif isinstance(specifier, base.MechanismName):
 4257            if startFrom is None:
 4258                startFrom = set()
 4259            return self.lookupMechanism(startFrom, specifier)
 4260
 4261        elif isinstance(specifier, tuple) and len(specifier) == 4:
 4262            domain, zone, decision, mechanism = specifier
 4263            if domain is None and zone is None and decision is None:
 4264                if startFrom is None:
 4265                    startFrom = set()
 4266                return self.lookupMechanism(startFrom, mechanism)
 4267
 4268            elif decision is not None:
 4269                startFrom = {
 4270                    self.resolveDecision(
 4271                        base.DecisionSpecifier(domain, zone, decision)
 4272                    )
 4273                }
 4274                return self.lookupMechanism(startFrom, mechanism)
 4275
 4276            else:  # decision is None but domain and/or zone aren't
 4277                startFrom = set()
 4278                if zone is not None:
 4279                    baseStart = self.allDecisionsInZone(zone)
 4280                else:
 4281                    baseStart = set(self)
 4282
 4283                if domain is None:
 4284                    startFrom = baseStart
 4285                else:
 4286                    for dID in baseStart:
 4287                        if self.domainFor(dID) == domain:
 4288                            startFrom.add(dID)
 4289                return self.lookupMechanism(startFrom, mechanism)
 4290
 4291        else:
 4292            raise TypeError(
 4293                f"Invalid mechanism specifier: {repr(specifier)}"
 4294                f"\n(Must be a mechanism ID, mechanism name, or"
 4295                f" mechanism specifier tuple)"
 4296            )
 4297
 4298    def walkConsequenceMechanisms(
 4299        self,
 4300        consequence: base.Consequence,
 4301        searchFrom: Set[base.DecisionID]
 4302    ) -> Generator[base.MechanismID, None, None]:
 4303        """
 4304        Yields each requirement in the given `base.Consequence`,
 4305        including those in `base.Condition`s, `base.ConditionalSkill`s
 4306        within `base.Challenge`s, and those set or toggled by
 4307        `base.Effect`s. The `searchFrom` argument specifies where to
 4308        start searching for mechanisms, since requirements include them
 4309        by name, not by ID.
 4310        """
 4311        for part in base.walkParts(consequence):
 4312            if isinstance(part, dict):
 4313                if 'skills' in part:  # a Challenge
 4314                    for cSkill in part['skills'].walk():
 4315                        if isinstance(cSkill, base.ConditionalSkill):
 4316                            yield from self.walkRequirementMechanisms(
 4317                                cSkill.requirement,
 4318                                searchFrom
 4319                            )
 4320                elif 'condition' in part:  # a Condition
 4321                    yield from self.walkRequirementMechanisms(
 4322                        part['condition'],
 4323                        searchFrom
 4324                    )
 4325                elif 'value' in part:  # an Effect
 4326                    val = part['value']
 4327                    if part['type'] == 'set':
 4328                        if (
 4329                            isinstance(val, tuple)
 4330                        and len(val) == 2
 4331                        and isinstance(val[1], base.State)
 4332                        ):
 4333                            yield from self.walkRequirementMechanisms(
 4334                                base.ReqMechanism(val[0], val[1]),
 4335                                searchFrom
 4336                            )
 4337                    elif part['type'] == 'toggle':
 4338                        if isinstance(val, tuple):
 4339                            assert len(val) == 2
 4340                            yield from self.walkRequirementMechanisms(
 4341                                base.ReqMechanism(val[0], '_'),
 4342                                  # state part is ignored here
 4343                                searchFrom
 4344                            )
 4345
 4346    def walkRequirementMechanisms(
 4347        self,
 4348        req: base.Requirement,
 4349        searchFrom: Set[base.DecisionID]
 4350    ) -> Generator[base.MechanismID, None, None]:
 4351        """
 4352        Given a requirement, yields any mechanisms mentioned in that
 4353        requirement, in depth-first traversal order.
 4354        """
 4355        for part in req.walk():
 4356            if isinstance(part, base.ReqMechanism):
 4357                mech = part.mechanism
 4358                yield self.resolveMechanism(
 4359                    mech,
 4360                    startFrom=searchFrom
 4361                )
 4362
 4363    def addUnexploredEdge(
 4364        self,
 4365        fromDecision: base.AnyDecisionSpecifier,
 4366        name: base.Transition,
 4367        destinationName: Optional[base.DecisionName] = None,
 4368        reciprocal: Optional[base.Transition] = 'return',
 4369        toDomain: Optional[base.Domain] = None,
 4370        placeInZone: Optional[base.Zone] = None,
 4371        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 4372        annotations: Optional[List[base.Annotation]] = None,
 4373        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 4374        revAnnotations: Optional[List[base.Annotation]] = None,
 4375        requires: Optional[base.Requirement] = None,
 4376        consequence: Optional[base.Consequence] = None,
 4377        revRequires: Optional[base.Requirement] = None,
 4378        revConsequece: Optional[base.Consequence] = None
 4379    ) -> base.DecisionID:
 4380        """
 4381        Adds a transition connecting to a new decision named `'_u.-n-'`
 4382        where '-n-' is the number of unknown decisions (named or not)
 4383        that have ever been created in this graph (or using the
 4384        specified destination name if one is provided). This represents
 4385        a transition to an unknown destination. The destination node
 4386        gets tagged 'unconfirmed'.
 4387
 4388        This also adds a reciprocal transition in the reverse direction,
 4389        unless `reciprocal` is set to `None`. The reciprocal will use
 4390        the provided name (default is 'return'). The new decision will
 4391        be in the same domain as the decision it's connected to, unless
 4392        `toDecision` is specified, in which case it will be in that
 4393        domain.
 4394
 4395        The new decision will not be placed into any zones, unless
 4396        `placeInZone` is specified, in which case it will be placed into
 4397        that zone. If that zone needs to be created, it will be created
 4398        at level 0; in that case that zone will be added to any
 4399        grandparent zones of the decision we're branching off of. If
 4400        `placeInZone` is set to `base.DefaultZone`, then the new
 4401        decision will be placed into each parent zone of the decision
 4402        we're branching off of, as long as the new decision is in the
 4403        same domain as the decision we're branching from (otherwise only
 4404        an explicit `placeInZone` would apply).
 4405
 4406        The ID of the decision that was created is returned.
 4407
 4408        A `MissingDecisionError` will be raised if the starting decision
 4409        does not exist, a `TransitionCollisionError` will be raised if
 4410        it exists but already has a transition with the given name, and a
 4411        `DecisionCollisionWarning` will be issued if a decision with the
 4412        specified destination name already exists (won't happen when
 4413        using an automatic name).
 4414
 4415        Lists of tags and/or annotations (strings in both cases) may be
 4416        provided. These may also be provided for the reciprocal edge.
 4417
 4418        Similarly, requirements and/or consequences for either edge may
 4419        be provided.
 4420
 4421        ## Example
 4422
 4423        >>> g = DecisionGraph()
 4424        >>> g.addDecision('A')
 4425        0
 4426        >>> g.addUnexploredEdge('A', 'up')
 4427        1
 4428        >>> g.nameFor(1)
 4429        '_u.0'
 4430        >>> g.decisionTags(1)
 4431        {'unconfirmed': 1}
 4432        >>> g.addUnexploredEdge('A', 'right', 'B')
 4433        2
 4434        >>> g.nameFor(2)
 4435        'B'
 4436        >>> g.decisionTags(2)
 4437        {'unconfirmed': 1}
 4438        >>> g.addUnexploredEdge('A', 'down', None, 'up')
 4439        3
 4440        >>> g.nameFor(3)
 4441        '_u.2'
 4442        >>> g.addUnexploredEdge(
 4443        ...    '_u.0',
 4444        ...    'beyond',
 4445        ...    toDomain='otherDomain',
 4446        ...    tags={'fast':1},
 4447        ...    revTags={'slow':1},
 4448        ...    annotations=['comment'],
 4449        ...    revAnnotations=['one', 'two'],
 4450        ...    requires=base.ReqCapability('dash'),
 4451        ...    revRequires=base.ReqCapability('super dash'),
 4452        ...    consequence=[base.effect(gain='super dash')],
 4453        ...    revConsequece=[base.effect(lose='super dash')]
 4454        ... )
 4455        4
 4456        >>> g.nameFor(4)
 4457        '_u.3'
 4458        >>> g.domainFor(4)
 4459        'otherDomain'
 4460        >>> g.transitionTags('_u.0', 'beyond')
 4461        {'fast': 1}
 4462        >>> g.transitionAnnotations('_u.0', 'beyond')
 4463        ['comment']
 4464        >>> g.getTransitionRequirement('_u.0', 'beyond')
 4465        ReqCapability('dash')
 4466        >>> e = g.getConsequence('_u.0', 'beyond')
 4467        >>> e == [base.effect(gain='super dash')]
 4468        True
 4469        >>> g.transitionTags('_u.3', 'return')
 4470        {'slow': 1}
 4471        >>> g.transitionAnnotations('_u.3', 'return')
 4472        ['one', 'two']
 4473        >>> g.getTransitionRequirement('_u.3', 'return')
 4474        ReqCapability('super dash')
 4475        >>> e = g.getConsequence('_u.3', 'return')
 4476        >>> e == [base.effect(lose='super dash')]
 4477        True
 4478        """
 4479        # Defaults
 4480        if tags is None:
 4481            tags = {}
 4482        if annotations is None:
 4483            annotations = []
 4484        if revTags is None:
 4485            revTags = {}
 4486        if revAnnotations is None:
 4487            revAnnotations = []
 4488
 4489        # Resolve ID
 4490        fromID = self.resolveDecision(fromDecision)
 4491        if toDomain is None:
 4492            toDomain = self.domainFor(fromID)
 4493
 4494        if name in self.destinationsFrom(fromID):
 4495            raise TransitionCollisionError(
 4496                f"Cannot add a new edge {name!r}:"
 4497                f" {self.identityOf(fromDecision)} already has an"
 4498                f" outgoing edge with that name."
 4499            )
 4500
 4501        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 4502            warnings.warn(
 4503                (
 4504                    f"Cannot add a new unexplored node"
 4505                    f" {destinationName!r}: A decision with that name"
 4506                    f" already exists.\n(Leave destinationName as None"
 4507                    f" to use an automatic name.)"
 4508                ),
 4509                DecisionCollisionWarning
 4510            )
 4511
 4512        # Create the new unexplored decision and add the edge
 4513        if destinationName is None:
 4514            toName = '_u.' + str(self.unknownCount)
 4515        else:
 4516            toName = destinationName
 4517        self.unknownCount += 1
 4518        newID = self.addDecision(toName, domain=toDomain)
 4519        self.addTransition(
 4520            fromID,
 4521            name,
 4522            newID,
 4523            tags=tags,
 4524            annotations=annotations
 4525        )
 4526        self.setTransitionRequirement(fromID, name, requires)
 4527        if consequence is not None:
 4528            self.setConsequence(fromID, name, consequence)
 4529
 4530        # Add it to a zone if requested
 4531        if (
 4532            placeInZone == base.DefaultZone
 4533        and toDomain == self.domainFor(fromID)
 4534        ):
 4535            # Add to each parent of the from decision
 4536            for parent in self.zoneParents(fromID):
 4537                self.addDecisionToZone(newID, parent)
 4538        elif placeInZone is not None:
 4539            # Otherwise add it to one specific zone, creating that zone
 4540            # at level 0 if necessary
 4541            assert isinstance(placeInZone, base.Zone)
 4542            if self.getZoneInfo(placeInZone) is None:
 4543                self.createZone(placeInZone, 0)
 4544                # Add new zone to each grandparent of the from decision
 4545                for parent in self.zoneParents(fromID):
 4546                    for grandparent in self.zoneParents(parent):
 4547                        self.addZoneToZone(placeInZone, grandparent)
 4548            self.addDecisionToZone(newID, placeInZone)
 4549
 4550        # Create the reciprocal edge
 4551        if reciprocal is not None:
 4552            self.addTransition(
 4553                newID,
 4554                reciprocal,
 4555                fromID,
 4556                tags=revTags,
 4557                annotations=revAnnotations
 4558            )
 4559            self.setTransitionRequirement(newID, reciprocal, revRequires)
 4560            if revConsequece is not None:
 4561                self.setConsequence(newID, reciprocal, revConsequece)
 4562            # Set as a reciprocal
 4563            self.setReciprocal(fromID, name, reciprocal)
 4564
 4565        # Tag the destination as 'unconfirmed'
 4566        self.tagDecision(newID, 'unconfirmed')
 4567
 4568        # Return ID of new destination
 4569        return newID
 4570
 4571    def retargetTransition(
 4572        self,
 4573        fromDecision: base.AnyDecisionSpecifier,
 4574        transition: base.Transition,
 4575        newDestination: base.AnyDecisionSpecifier,
 4576        swapReciprocal=True,
 4577        errorOnNameColision=True
 4578    ) -> Optional[base.Transition]:
 4579        """
 4580        Given a particular decision and a transition at that decision,
 4581        changes that transition so that it goes to the specified new
 4582        destination instead of wherever it was connected to before. If
 4583        the new destination is the same as the old one, no changes are
 4584        made.
 4585
 4586        If `swapReciprocal` is set to True (the default) then any
 4587        reciprocal edge at the old destination will be deleted, and a
 4588        new reciprocal edge from the new destination with equivalent
 4589        properties to the original reciprocal will be created, pointing
 4590        to the origin of the specified transition. If `swapReciprocal`
 4591        is set to False, then the reciprocal relationship with any old
 4592        reciprocal edge will be removed, but the old reciprocal edge
 4593        will not be changed.
 4594
 4595        Note that if `errorOnNameColision` is True (the default), then
 4596        if the reciprocal transition has the same name as a transition
 4597        which already exists at the new destination node, a
 4598        `TransitionCollisionError` will be thrown. However, if it is set
 4599        to False, the reciprocal transition will be renamed with a suffix
 4600        to avoid any possible name collisions. Either way, the name of
 4601        the reciprocal transition (possibly just changed) will be
 4602        returned, or None if there was no reciprocal transition.
 4603
 4604        ## Example
 4605
 4606        >>> g = DecisionGraph()
 4607        >>> for fr, to, nm in [
 4608        ...     ('A', 'B', 'up'),
 4609        ...     ('A', 'B', 'up2'),
 4610        ...     ('B', 'A', 'down'),
 4611        ...     ('B', 'B', 'self'),
 4612        ...     ('B', 'C', 'next'),
 4613        ...     ('C', 'B', 'prev')
 4614        ... ]:
 4615        ...     if g.getDecision(fr) is None:
 4616        ...        g.addDecision(fr)
 4617        ...     if g.getDecision(to) is None:
 4618        ...         g.addDecision(to)
 4619        ...     g.addTransition(fr, nm, to)
 4620        0
 4621        1
 4622        2
 4623        >>> g.setReciprocal('A', 'up', 'down')
 4624        >>> g.setReciprocal('B', 'next', 'prev')
 4625        >>> g.destination('A', 'up')
 4626        1
 4627        >>> g.destination('B', 'down')
 4628        0
 4629        >>> g.retargetTransition('A', 'up', 'C')
 4630        'down'
 4631        >>> g.destination('A', 'up')
 4632        2
 4633        >>> g.getDestination('B', 'down') is None
 4634        True
 4635        >>> g.destination('C', 'down')
 4636        0
 4637        >>> g.addTransition('A', 'next', 'B')
 4638        >>> g.addTransition('B', 'prev', 'A')
 4639        >>> g.setReciprocal('A', 'next', 'prev')
 4640        >>> # Can't swap a reciprocal in a way that would collide names
 4641        >>> g.getReciprocal('C', 'prev')
 4642        'next'
 4643        >>> g.retargetTransition('C', 'prev', 'A')
 4644        Traceback (most recent call last):
 4645        ...
 4646        exploration.core.TransitionCollisionError...
 4647        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
 4648        'next'
 4649        >>> g.destination('C', 'prev')
 4650        0
 4651        >>> g.destination('A', 'next') # not changed
 4652        1
 4653        >>> # Reciprocal relationship is severed:
 4654        >>> g.getReciprocal('C', 'prev') is None
 4655        True
 4656        >>> g.getReciprocal('B', 'next') is None
 4657        True
 4658        >>> # Swap back so we can do another demo
 4659        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
 4660        >>> # Note return value was None here because there was no reciprocal
 4661        >>> g.setReciprocal('C', 'prev', 'next')
 4662        >>> # Swap reciprocal by renaming it
 4663        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
 4664        'next.1'
 4665        >>> g.getReciprocal('C', 'prev')
 4666        'next.1'
 4667        >>> g.destination('C', 'prev')
 4668        0
 4669        >>> g.destination('A', 'next.1')
 4670        2
 4671        >>> g.destination('A', 'next')
 4672        1
 4673        >>> # Note names are the same but these are from different nodes
 4674        >>> g.getReciprocal('A', 'next')
 4675        'prev'
 4676        >>> g.getReciprocal('A', 'next.1')
 4677        'prev'
 4678        """
 4679        fromID = self.resolveDecision(fromDecision)
 4680        newDestID = self.resolveDecision(newDestination)
 4681
 4682        # Figure out the old destination of the transition we're swapping
 4683        oldDestID = self.destination(fromID, transition)
 4684        reciprocal = self.getReciprocal(fromID, transition)
 4685
 4686        # If thew new destination is the same, we don't do anything!
 4687        if oldDestID == newDestID:
 4688            return reciprocal
 4689
 4690        # First figure out reciprocal business so we can error out
 4691        # without making changes if we need to
 4692        if swapReciprocal and reciprocal is not None:
 4693            reciprocal = self.rebaseTransition(
 4694                oldDestID,
 4695                reciprocal,
 4696                newDestID,
 4697                swapReciprocal=False,
 4698                errorOnNameColision=errorOnNameColision
 4699            )
 4700
 4701        # Handle the forward transition...
 4702        # Find the transition properties
 4703        tProps = self.getTransitionProperties(fromID, transition)
 4704
 4705        # Delete the edge
 4706        self.removeEdgeByKey(fromID, transition)
 4707
 4708        # Add the new edge
 4709        self.addTransition(fromID, transition, newDestID)
 4710
 4711        # Reapply the transition properties
 4712        self.setTransitionProperties(fromID, transition, **tProps)
 4713
 4714        # Handle the reciprocal transition if there is one...
 4715        if reciprocal is not None:
 4716            if not swapReciprocal:
 4717                # Then sever the relationship, but only if that edge
 4718                # still exists (we might be in the middle of a rebase)
 4719                check = self.getDestination(oldDestID, reciprocal)
 4720                if check is not None:
 4721                    self.setReciprocal(
 4722                        oldDestID,
 4723                        reciprocal,
 4724                        None,
 4725                        setBoth=False # Other transition was deleted already
 4726                    )
 4727            else:
 4728                # Establish new reciprocal relationship
 4729                self.setReciprocal(
 4730                    fromID,
 4731                    transition,
 4732                    reciprocal
 4733                )
 4734
 4735        return reciprocal
 4736
 4737    def rebaseTransition(
 4738        self,
 4739        fromDecision: base.AnyDecisionSpecifier,
 4740        transition: base.Transition,
 4741        newBase: base.AnyDecisionSpecifier,
 4742        swapReciprocal=True,
 4743        errorOnNameColision=True
 4744    ) -> base.Transition:
 4745        """
 4746        Given a particular destination and a transition at that
 4747        destination, changes that transition's origin to a new base
 4748        decision. If the new source is the same as the old one, no
 4749        changes are made.
 4750
 4751        If `swapReciprocal` is set to True (the default) then any
 4752        reciprocal edge at the destination will be retargeted to point
 4753        to the new source so that it can remain a reciprocal. If
 4754        `swapReciprocal` is set to False, then the reciprocal
 4755        relationship with any old reciprocal edge will be removed, but
 4756        the old reciprocal edge will not be otherwise changed.
 4757
 4758        Note that if `errorOnNameColision` is True (the default), then
 4759        if the transition has the same name as a transition which
 4760        already exists at the new source node, a
 4761        `TransitionCollisionError` will be raised. However, if it is set
 4762        to False, the transition will be renamed with a suffix to avoid
 4763        any possible name collisions. Either way, the (possibly new) name
 4764        of the transition that was rebased will be returned.
 4765
 4766        ## Example
 4767
 4768        >>> g = DecisionGraph()
 4769        >>> for fr, to, nm in [
 4770        ...     ('A', 'B', 'up'),
 4771        ...     ('A', 'B', 'up2'),
 4772        ...     ('B', 'A', 'down'),
 4773        ...     ('B', 'B', 'self'),
 4774        ...     ('B', 'C', 'next'),
 4775        ...     ('C', 'B', 'prev')
 4776        ... ]:
 4777        ...     if g.getDecision(fr) is None:
 4778        ...        g.addDecision(fr)
 4779        ...     if g.getDecision(to) is None:
 4780        ...         g.addDecision(to)
 4781        ...     g.addTransition(fr, nm, to)
 4782        0
 4783        1
 4784        2
 4785        >>> g.setReciprocal('A', 'up', 'down')
 4786        >>> g.setReciprocal('B', 'next', 'prev')
 4787        >>> g.destination('A', 'up')
 4788        1
 4789        >>> g.destination('B', 'down')
 4790        0
 4791        >>> g.rebaseTransition('B', 'down', 'C')
 4792        'down'
 4793        >>> g.destination('A', 'up')
 4794        2
 4795        >>> g.getDestination('B', 'down') is None
 4796        True
 4797        >>> g.destination('C', 'down')
 4798        0
 4799        >>> g.addTransition('A', 'next', 'B')
 4800        >>> g.addTransition('B', 'prev', 'A')
 4801        >>> g.setReciprocal('A', 'next', 'prev')
 4802        >>> # Can't rebase in a way that would collide names
 4803        >>> g.rebaseTransition('B', 'next', 'A')
 4804        Traceback (most recent call last):
 4805        ...
 4806        exploration.core.TransitionCollisionError...
 4807        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
 4808        'next.1'
 4809        >>> g.destination('C', 'prev')
 4810        0
 4811        >>> g.destination('A', 'next') # not changed
 4812        1
 4813        >>> # Collision is avoided by renaming
 4814        >>> g.destination('A', 'next.1')
 4815        2
 4816        >>> # Swap without reciprocal
 4817        >>> g.getReciprocal('A', 'next.1')
 4818        'prev'
 4819        >>> g.getReciprocal('C', 'prev')
 4820        'next.1'
 4821        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
 4822        'next.1'
 4823        >>> g.getReciprocal('C', 'prev') is None
 4824        True
 4825        >>> g.destination('C', 'prev')
 4826        0
 4827        >>> g.getDestination('A', 'next.1') is None
 4828        True
 4829        >>> g.destination('A', 'next')
 4830        1
 4831        >>> g.destination('B', 'next.1')
 4832        2
 4833        >>> g.getReciprocal('B', 'next.1') is None
 4834        True
 4835        >>> # Rebase in a way that creates a self-edge
 4836        >>> g.rebaseTransition('A', 'next', 'B')
 4837        'next'
 4838        >>> g.getDestination('A', 'next') is None
 4839        True
 4840        >>> g.destination('B', 'next')
 4841        1
 4842        >>> g.destination('B', 'prev') # swapped as a reciprocal
 4843        1
 4844        >>> g.getReciprocal('B', 'next') # still reciprocals
 4845        'prev'
 4846        >>> g.getReciprocal('B', 'prev')
 4847        'next'
 4848        >>> # And rebasing of a self-edge also works
 4849        >>> g.rebaseTransition('B', 'prev', 'A')
 4850        'prev'
 4851        >>> g.destination('A', 'prev')
 4852        1
 4853        >>> g.destination('B', 'next')
 4854        0
 4855        >>> g.getReciprocal('B', 'next') # still reciprocals
 4856        'prev'
 4857        >>> g.getReciprocal('A', 'prev')
 4858        'next'
 4859        >>> # We've effectively reversed this edge/reciprocal pair
 4860        >>> # by rebasing twice
 4861        """
 4862        fromID = self.resolveDecision(fromDecision)
 4863        newBaseID = self.resolveDecision(newBase)
 4864
 4865        # If thew new base is the same, we don't do anything!
 4866        if newBaseID == fromID:
 4867            return transition
 4868
 4869        # First figure out reciprocal business so we can swap it later
 4870        # without making changes if we need to
 4871        destination = self.destination(fromID, transition)
 4872        reciprocal = self.getReciprocal(fromID, transition)
 4873        # Check for an already-deleted reciprocal
 4874        if (
 4875            reciprocal is not None
 4876        and self.getDestination(destination, reciprocal) is None
 4877        ):
 4878            reciprocal = None
 4879
 4880        # Handle the base swap...
 4881        # Find the transition properties
 4882        tProps = self.getTransitionProperties(fromID, transition)
 4883
 4884        # Check for a collision
 4885        targetDestinations = self.destinationsFrom(newBaseID)
 4886        if transition in targetDestinations:
 4887            if errorOnNameColision:
 4888                raise TransitionCollisionError(
 4889                    f"Cannot rebase transition {transition!r} from"
 4890                    f" {self.identityOf(fromDecision)}: it would be a"
 4891                    f" duplicate transition name at the new base"
 4892                    f" decision {self.identityOf(newBase)}."
 4893                )
 4894            else:
 4895                # Figure out a good fresh name
 4896                newName = utils.uniqueName(
 4897                    transition,
 4898                    targetDestinations
 4899                )
 4900        else:
 4901            newName = transition
 4902
 4903        # Delete the edge
 4904        self.removeEdgeByKey(fromID, transition)
 4905
 4906        # Add the new edge
 4907        self.addTransition(newBaseID, newName, destination)
 4908
 4909        # Reapply the transition properties
 4910        self.setTransitionProperties(newBaseID, newName, **tProps)
 4911
 4912        # Handle the reciprocal transition if there is one...
 4913        if reciprocal is not None:
 4914            if not swapReciprocal:
 4915                # Then sever the relationship
 4916                self.setReciprocal(
 4917                    destination,
 4918                    reciprocal,
 4919                    None,
 4920                    setBoth=False # Other transition was deleted already
 4921                )
 4922            else:
 4923                # Otherwise swap the reciprocal edge
 4924                self.retargetTransition(
 4925                    destination,
 4926                    reciprocal,
 4927                    newBaseID,
 4928                    swapReciprocal=False
 4929                )
 4930
 4931                # And establish a new reciprocal relationship
 4932                self.setReciprocal(
 4933                    newBaseID,
 4934                    newName,
 4935                    reciprocal
 4936                )
 4937
 4938        # Return the new name in case it was changed
 4939        return newName
 4940
 4941    # TODO: zone merging!
 4942
 4943    # TODO: Double-check that exploration vars get updated when this is
 4944    # called!
 4945    def mergeDecisions(
 4946        self,
 4947        merge: base.AnyDecisionSpecifier,
 4948        mergeInto: base.AnyDecisionSpecifier,
 4949        errorOnNameColision=True
 4950    ) -> Dict[base.Transition, base.Transition]:
 4951        """
 4952        Merges two decisions, deleting the first after transferring all
 4953        of its incoming and outgoing edges to target the second one,
 4954        whose name is retained. The second decision will be added to any
 4955        zones that the first decision was a member of. If either decision
 4956        does not exist, a `MissingDecisionError` will be raised. If
 4957        `merge` and `mergeInto` are the same, then nothing will be
 4958        changed.
 4959
 4960        Unless `errorOnNameColision` is set to False, a
 4961        `TransitionCollisionError` will be raised if the two decisions
 4962        have outgoing transitions with the same name. If
 4963        `errorOnNameColision` is set to False, then such edges will be
 4964        renamed using a suffix to avoid name collisions, with edges
 4965        connected to the second decision retaining their original names
 4966        and edges that were connected to the first decision getting
 4967        renamed.
 4968
 4969        Any mechanisms located at the first decision will be moved to the
 4970        merged decision.
 4971
 4972        The tags and annotations of the merged decision are added to the
 4973        tags and annotations of the merge target. If there are shared
 4974        tags, the values from the merge target will override those of
 4975        the merged decision. If this is undesired behavior, clear/edit
 4976        the tags/annotations of the merged decision before the merge.
 4977
 4978        The 'unconfirmed' tag is treated specially: if both decisions have
 4979        it it will be retained, but otherwise it will be dropped even if
 4980        one of the situations had it before.
 4981
 4982        The domain of the second decision is retained.
 4983
 4984        Returns a dictionary mapping each original transition name to
 4985        its new name in cases where transitions get renamed; this will
 4986        be empty when no re-naming occurs, including when
 4987        `errorOnNameColision` is True. If there were any transitions
 4988        connecting the nodes that were merged, these become self-edges
 4989        of the merged node (and may be renamed if necessary).
 4990        Note that all renamed transitions were originally based on the
 4991        first (merged) node, since transitions of the second (merge
 4992        target) node are not renamed.
 4993
 4994        ## Example
 4995
 4996        >>> g = DecisionGraph()
 4997        >>> for fr, to, nm in [
 4998        ...     ('A', 'B', 'up'),
 4999        ...     ('A', 'B', 'up2'),
 5000        ...     ('B', 'A', 'down'),
 5001        ...     ('B', 'B', 'self'),
 5002        ...     ('B', 'C', 'next'),
 5003        ...     ('C', 'B', 'prev'),
 5004        ...     ('A', 'C', 'right')
 5005        ... ]:
 5006        ...     if g.getDecision(fr) is None:
 5007        ...        g.addDecision(fr)
 5008        ...     if g.getDecision(to) is None:
 5009        ...         g.addDecision(to)
 5010        ...     g.addTransition(fr, nm, to)
 5011        0
 5012        1
 5013        2
 5014        >>> g.getDestination('A', 'up')
 5015        1
 5016        >>> g.getDestination('B', 'down')
 5017        0
 5018        >>> sorted(g)
 5019        [0, 1, 2]
 5020        >>> g.setReciprocal('A', 'up', 'down')
 5021        >>> g.setReciprocal('B', 'next', 'prev')
 5022        >>> g.mergeDecisions('C', 'B')
 5023        {}
 5024        >>> g.destinationsFrom('A')
 5025        {'up': 1, 'up2': 1, 'right': 1}
 5026        >>> g.destinationsFrom('B')
 5027        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
 5028        >>> 'C' in g
 5029        False
 5030        >>> g.mergeDecisions('A', 'A') # does nothing
 5031        {}
 5032        >>> # Can't merge non-existent decision
 5033        >>> g.mergeDecisions('A', 'Z')
 5034        Traceback (most recent call last):
 5035        ...
 5036        exploration.core.MissingDecisionError...
 5037        >>> g.mergeDecisions('Z', 'A')
 5038        Traceback (most recent call last):
 5039        ...
 5040        exploration.core.MissingDecisionError...
 5041        >>> # Can't merge decisions w/ shared edge names
 5042        >>> g.addDecision('D')
 5043        3
 5044        >>> g.addTransition('D', 'next', 'A')
 5045        >>> g.addTransition('A', 'prev', 'D')
 5046        >>> g.setReciprocal('D', 'next', 'prev')
 5047        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
 5048        Traceback (most recent call last):
 5049        ...
 5050        exploration.core.TransitionCollisionError...
 5051        >>> # Auto-rename colliding edges
 5052        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
 5053        {'next': 'next.1'}
 5054        >>> g.destination('B', 'next') # merge target unchanged
 5055        1
 5056        >>> g.destination('B', 'next.1') # merged decision name changed
 5057        0
 5058        >>> g.destination('B', 'prev') # name unchanged (no collision)
 5059        1
 5060        >>> g.getReciprocal('B', 'next') # unchanged (from B)
 5061        'prev'
 5062        >>> g.getReciprocal('B', 'next.1') # from A
 5063        'prev'
 5064        >>> g.getReciprocal('A', 'prev') # from B
 5065        'next.1'
 5066
 5067        ## Folding four nodes into a 2-node loop
 5068
 5069        >>> g = DecisionGraph()
 5070        >>> g.addDecision('X')
 5071        0
 5072        >>> g.addDecision('Y')
 5073        1
 5074        >>> g.addTransition('X', 'next', 'Y', 'prev')
 5075        >>> g.addDecision('preX')
 5076        2
 5077        >>> g.addDecision('postY')
 5078        3
 5079        >>> g.addTransition('preX', 'next', 'X', 'prev')
 5080        >>> g.addTransition('Y', 'next', 'postY', 'prev')
 5081        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
 5082        {'next': 'next.1'}
 5083        >>> g.destinationsFrom('X')
 5084        {'next': 1, 'prev': 1}
 5085        >>> g.destinationsFrom('Y')
 5086        {'prev': 0, 'next': 3, 'next.1': 0}
 5087        >>> 2 in g
 5088        False
 5089        >>> g.destinationsFrom('postY')
 5090        {'prev': 1}
 5091        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
 5092        {'prev': 'prev.1'}
 5093        >>> g.destinationsFrom('X')
 5094        {'next': 1, 'prev': 1, 'prev.1': 1}
 5095        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
 5096        {'prev': 0, 'next.1': 0, 'next': 0}
 5097        >>> 2 in g
 5098        False
 5099        >>> 3 in g
 5100        False
 5101        >>> # Reciprocals are tangled...
 5102        >>> g.getReciprocal(0, 'prev')
 5103        'next.1'
 5104        >>> g.getReciprocal(0, 'prev.1')
 5105        'next'
 5106        >>> g.getReciprocal(1, 'next')
 5107        'prev.1'
 5108        >>> g.getReciprocal(1, 'next.1')
 5109        'prev'
 5110        >>> # Note: one merge cannot handle both extra transitions
 5111        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
 5112        >>> # (It would merge both edges but the result would retain
 5113        >>> # 'next.1' instead of retaining 'next'.)
 5114        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
 5115        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
 5116        >>> g.destinationsFrom('X')
 5117        {'next': 1, 'prev': 1}
 5118        >>> g.destinationsFrom('Y')
 5119        {'prev': 0, 'next': 0}
 5120        >>> # Reciprocals were salvaged in second merger
 5121        >>> g.getReciprocal('X', 'prev')
 5122        'next'
 5123        >>> g.getReciprocal('Y', 'next')
 5124        'prev'
 5125
 5126        ## Merging with tags/requirements/annotations/consequences
 5127
 5128        >>> g = DecisionGraph()
 5129        >>> g.addDecision('X')
 5130        0
 5131        >>> g.addDecision('Y')
 5132        1
 5133        >>> g.addDecision('Z')
 5134        2
 5135        >>> g.addTransition('X', 'next', 'Y', 'prev')
 5136        >>> g.addTransition('X', 'down', 'Z', 'up')
 5137        >>> g.tagDecision('X', 'tag0', 1)
 5138        >>> g.tagDecision('Y', 'tag1', 10)
 5139        >>> g.tagDecision('Y', 'unconfirmed')
 5140        >>> g.tagDecision('Z', 'tag1', 20)
 5141        >>> g.tagDecision('Z', 'tag2', 30)
 5142        >>> g.tagTransition('X', 'next', 'ttag1', 11)
 5143        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
 5144        >>> g.tagTransition('X', 'down', 'ttag3', 33)
 5145        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
 5146        >>> g.annotateDecision('Y', 'annotation 1')
 5147        >>> g.annotateDecision('Z', 'annotation 2')
 5148        >>> g.annotateDecision('Z', 'annotation 3')
 5149        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
 5150        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
 5151        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
 5152        >>> g.setTransitionRequirement(
 5153        ...     'X',
 5154        ...     'next',
 5155        ...     base.ReqCapability('power')
 5156        ... )
 5157        >>> g.setTransitionRequirement(
 5158        ...     'Y',
 5159        ...     'prev',
 5160        ...     base.ReqTokens('token', 1)
 5161        ... )
 5162        >>> g.setTransitionRequirement(
 5163        ...     'X',
 5164        ...     'down',
 5165        ...     base.ReqCapability('power2')
 5166        ... )
 5167        >>> g.setTransitionRequirement(
 5168        ...     'Z',
 5169        ...     'up',
 5170        ...     base.ReqTokens('token2', 2)
 5171        ... )
 5172        >>> g.setConsequence(
 5173        ...     'Y',
 5174        ...     'prev',
 5175        ...     [base.effect(gain="power2")]
 5176        ... )
 5177        >>> g.mergeDecisions('Y', 'Z')
 5178        {}
 5179        >>> g.destination('X', 'next')
 5180        2
 5181        >>> g.destination('X', 'down')
 5182        2
 5183        >>> g.destination('Z', 'prev')
 5184        0
 5185        >>> g.destination('Z', 'up')
 5186        0
 5187        >>> g.decisionTags('X')
 5188        {'tag0': 1}
 5189        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
 5190        {'tag1': 20, 'tag2': 30}
 5191        >>> g.transitionTags('X', 'next')
 5192        {'ttag1': 11}
 5193        >>> g.transitionTags('X', 'down')
 5194        {'ttag3': 33}
 5195        >>> g.transitionTags('Z', 'prev')
 5196        {'ttag2': 22}
 5197        >>> g.transitionTags('Z', 'up')
 5198        {'ttag4': 44}
 5199        >>> g.decisionAnnotations('Z')
 5200        ['annotation 2', 'annotation 3', 'annotation 1']
 5201        >>> g.transitionAnnotations('Z', 'prev')
 5202        ['trans annotation 1', 'trans annotation 2']
 5203        >>> g.transitionAnnotations('Z', 'up')
 5204        ['trans annotation 3']
 5205        >>> g.getTransitionRequirement('X', 'next')
 5206        ReqCapability('power')
 5207        >>> g.getTransitionRequirement('Z', 'prev')
 5208        ReqTokens('token', 1)
 5209        >>> g.getTransitionRequirement('X', 'down')
 5210        ReqCapability('power2')
 5211        >>> g.getTransitionRequirement('Z', 'up')
 5212        ReqTokens('token2', 2)
 5213        >>> g.getConsequence('Z', 'prev') == [
 5214        ...     {
 5215        ...         'type': 'gain',
 5216        ...         'applyTo': 'active',
 5217        ...         'value': 'power2',
 5218        ...         'charges': None,
 5219        ...         'delay': None,
 5220        ...         'hidden': False
 5221        ...     }
 5222        ... ]
 5223        True
 5224
 5225        ## Merging into node without tags
 5226
 5227        >>> g = DecisionGraph()
 5228        >>> g.addDecision('X')
 5229        0
 5230        >>> g.addDecision('Y')
 5231        1
 5232        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
 5233        >>> g.tagDecision('Y', 'tag', 'value')
 5234        >>> g.mergeDecisions('Y', 'X')
 5235        {}
 5236        >>> g.decisionTags('X')
 5237        {'tag': 'value'}
 5238        >>> 0 in g  # Second argument remains
 5239        True
 5240        >>> 1 in g  # First argument is deleted
 5241        False
 5242        """
 5243        # Resolve IDs
 5244        mergeID = self.resolveDecision(merge)
 5245        mergeIntoID = self.resolveDecision(mergeInto)
 5246
 5247        # Create our result as an empty dictionary
 5248        result: Dict[base.Transition, base.Transition] = {}
 5249
 5250        # Short-circuit if the two decisions are the same
 5251        if mergeID == mergeIntoID:
 5252            return result
 5253
 5254        # MissingDecisionErrors from here if either doesn't exist
 5255        allNewOutgoing = set(self.destinationsFrom(mergeID))
 5256        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
 5257        # Find colliding transition names
 5258        collisions = allNewOutgoing & allOldOutgoing
 5259        if len(collisions) > 0 and errorOnNameColision:
 5260            raise TransitionCollisionError(
 5261                f"Cannot merge decision {self.identityOf(merge)} into"
 5262                f" decision {self.identityOf(mergeInto)}: the decisions"
 5263                f" share {len(collisions)} transition names:"
 5264                f" {collisions}\n(Note that errorOnNameColision was set"
 5265                f" to True, set it to False to allow the operation by"
 5266                f" renaming half of those transitions.)"
 5267            )
 5268
 5269        # Record zones that will have to change after the merge
 5270        zoneParents = self.zoneParents(mergeID)
 5271
 5272        # First, swap all incoming edges, along with their reciprocals
 5273        # This will include self-edges, which will be retargeted and
 5274        # whose reciprocals will be rebased in the process, leading to
 5275        # the possibility of a missing edge during the loop
 5276        for source, incoming in self.allEdgesTo(mergeID):
 5277            # Skip this edge if it was already swapped away because it's
 5278            # a self-loop with a reciprocal whose reciprocal was
 5279            # processed earlier in the loop
 5280            if incoming not in self.destinationsFrom(source):
 5281                continue
 5282
 5283            # Find corresponding outgoing edge
 5284            outgoing = self.getReciprocal(source, incoming)
 5285
 5286            # Swap both edges to new destination
 5287            newOutgoing = self.retargetTransition(
 5288                source,
 5289                incoming,
 5290                mergeIntoID,
 5291                swapReciprocal=True,
 5292                errorOnNameColision=False # collisions were detected above
 5293            )
 5294            # Add to our result if the name of the reciprocal was
 5295            # changed
 5296            if (
 5297                outgoing is not None
 5298            and newOutgoing is not None
 5299            and outgoing != newOutgoing
 5300            ):
 5301                result[outgoing] = newOutgoing
 5302
 5303        # Next, swap any remaining outgoing edges (which didn't have
 5304        # reciprocals, or they'd already be swapped, unless they were
 5305        # self-edges previously). Note that in this loop, there can't be
 5306        # any self-edges remaining, although there might be connections
 5307        # between the merging nodes that need to become self-edges
 5308        # because they used to be a self-edge that was half-retargeted
 5309        # by the previous loop.
 5310        # Note: a copy is used here to avoid iterating over a changing
 5311        # dictionary
 5312        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
 5313            newOutgoing = self.rebaseTransition(
 5314                mergeID,
 5315                stillOutgoing,
 5316                mergeIntoID,
 5317                swapReciprocal=True,
 5318                errorOnNameColision=False # collisions were detected above
 5319            )
 5320            if stillOutgoing != newOutgoing:
 5321                result[stillOutgoing] = newOutgoing
 5322
 5323        # At this point, there shouldn't be any remaining incoming or
 5324        # outgoing edges!
 5325        assert self.degree(mergeID) == 0
 5326
 5327        # Merge tags & annotations
 5328        # Note that these operations affect the underlying graph
 5329        destTags = self.decisionTags(mergeIntoID)
 5330        destUnvisited = 'unconfirmed' in destTags
 5331        sourceTags = self.decisionTags(mergeID)
 5332        sourceUnvisited = 'unconfirmed' in sourceTags
 5333        # Copy over only new tags, leaving existing tags alone
 5334        for key in sourceTags:
 5335            if key not in destTags:
 5336                destTags[key] = sourceTags[key]
 5337
 5338        if int(destUnvisited) + int(sourceUnvisited) == 1:
 5339            del destTags['unconfirmed']
 5340
 5341        self.decisionAnnotations(mergeIntoID).extend(
 5342            self.decisionAnnotations(mergeID)
 5343        )
 5344
 5345        # Transfer zones
 5346        for zone in zoneParents:
 5347            self.addDecisionToZone(mergeIntoID, zone)
 5348
 5349        # Delete the old node
 5350        self.removeDecision(mergeID)
 5351
 5352        return result
 5353
 5354    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
 5355        """
 5356        Deletes the specified decision from the graph, updating
 5357        attendant structures like zones. Note that the ID of the deleted
 5358        node will NOT be reused, unless it's specifically provided to
 5359        `addIdentifiedDecision`.
 5360
 5361        For example:
 5362
 5363        >>> dg = DecisionGraph()
 5364        >>> dg.addDecision('A')
 5365        0
 5366        >>> dg.addDecision('B')
 5367        1
 5368        >>> list(dg)
 5369        [0, 1]
 5370        >>> 1 in dg
 5371        True
 5372        >>> 'B' in dg.nameLookup
 5373        True
 5374        >>> dg.removeDecision('B')
 5375        >>> 1 in dg
 5376        False
 5377        >>> list(dg)
 5378        [0]
 5379        >>> 'B' in dg.nameLookup
 5380        False
 5381        >>> dg.addDecision('C')  # doesn't re-use ID
 5382        2
 5383        """
 5384        dID = self.resolveDecision(decision)
 5385
 5386        # Remove the target from all zones:
 5387        for zone in self.zones:
 5388            self.removeDecisionFromZone(dID, zone)
 5389
 5390        # Remove the node but record the current name
 5391        name = self.nodes[dID]['name']
 5392        self.remove_node(dID)
 5393
 5394        # Clean up the nameLookup entry
 5395        luInfo = self.nameLookup[name]
 5396        luInfo.remove(dID)
 5397        if len(luInfo) == 0:
 5398            self.nameLookup.pop(name)
 5399
 5400        # TODO: Clean up edges?
 5401
 5402    def renameDecision(
 5403        self,
 5404        decision: base.AnyDecisionSpecifier,
 5405        newName: base.DecisionName
 5406    ):
 5407        """
 5408        Renames a decision. The decision retains its old ID.
 5409
 5410        Generates a `DecisionCollisionWarning` if a decision using the new
 5411        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
 5412
 5413        Example:
 5414
 5415        >>> g = DecisionGraph()
 5416        >>> g.addDecision('one')
 5417        0
 5418        >>> g.addDecision('three')
 5419        1
 5420        >>> g.addTransition('one', '>', 'three')
 5421        >>> g.addTransition('three', '<', 'one')
 5422        >>> g.tagDecision('three', 'hi')
 5423        >>> g.annotateDecision('three', 'note')
 5424        >>> g.destination('one', '>')
 5425        1
 5426        >>> g.destination('three', '<')
 5427        0
 5428        >>> g.renameDecision('three', 'two')
 5429        >>> g.resolveDecision('one')
 5430        0
 5431        >>> g.resolveDecision('two')
 5432        1
 5433        >>> g.resolveDecision('three')
 5434        Traceback (most recent call last):
 5435        ...
 5436        exploration.core.MissingDecisionError...
 5437        >>> g.destination('one', '>')
 5438        1
 5439        >>> g.nameFor(1)
 5440        'two'
 5441        >>> g.getDecision('three') is None
 5442        True
 5443        >>> g.destination('two', '<')
 5444        0
 5445        >>> g.decisionTags('two')
 5446        {'hi': 1}
 5447        >>> g.decisionAnnotations('two')
 5448        ['note']
 5449        """
 5450        dID = self.resolveDecision(decision)
 5451
 5452        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 5453            warnings.warn(
 5454                (
 5455                    f"Can't rename {self.identityOf(decision)} as"
 5456                    f" {newName!r} because a decision with that name"
 5457                    f" already exists."
 5458                ),
 5459                DecisionCollisionWarning
 5460            )
 5461
 5462        # Update name in node
 5463        oldName = self.nodes[dID]['name']
 5464        self.nodes[dID]['name'] = newName
 5465
 5466        # Update nameLookup entries
 5467        oldNL = self.nameLookup[oldName]
 5468        oldNL.remove(dID)
 5469        if len(oldNL) == 0:
 5470            self.nameLookup.pop(oldName)
 5471        self.nameLookup.setdefault(newName, []).append(dID)
 5472
 5473    def mergeTransitions(
 5474        self,
 5475        fromDecision: base.AnyDecisionSpecifier,
 5476        merge: base.Transition,
 5477        mergeInto: base.Transition,
 5478        mergeReciprocal=True
 5479    ) -> None:
 5480        """
 5481        Given a decision and two transitions that start at that decision,
 5482        merges the first transition into the second transition, combining
 5483        their transition properties (using `mergeProperties`) and
 5484        deleting the first transition. By default any reciprocal of the
 5485        first transition is also merged into the reciprocal of the
 5486        second, although you can set `mergeReciprocal` to `False` to
 5487        disable this in which case the old reciprocal will lose its
 5488        reciprocal relationship, even if the transition that was merged
 5489        into does not have a reciprocal.
 5490
 5491        If the two names provided are the same, nothing will happen.
 5492
 5493        If the two transitions do not share the same destination, they
 5494        cannot be merged, and an `InvalidDestinationError` will result.
 5495        Use `retargetTransition` beforehand to ensure that they do if you
 5496        want to merge transitions with different destinations.
 5497
 5498        A `MissingDecisionError` or `MissingTransitionError` will result
 5499        if the decision or either transition does not exist.
 5500
 5501        If merging reciprocal properties was requested and the first
 5502        transition does not have a reciprocal, then no reciprocal
 5503        properties change. However, if the second transition does not
 5504        have a reciprocal and the first does, the first transition's
 5505        reciprocal will be set to the reciprocal of the second
 5506        transition, and that transition will not be deleted as usual.
 5507
 5508        ## Example
 5509
 5510        >>> g = DecisionGraph()
 5511        >>> g.addDecision('A')
 5512        0
 5513        >>> g.addDecision('B')
 5514        1
 5515        >>> g.addTransition('A', 'up', 'B')
 5516        >>> g.addTransition('B', 'down', 'A')
 5517        >>> g.setReciprocal('A', 'up', 'down')
 5518        >>> # Merging a transition with no reciprocal
 5519        >>> g.addTransition('A', 'up2', 'B')
 5520        >>> g.mergeTransitions('A', 'up2', 'up')
 5521        >>> g.getDestination('A', 'up2') is None
 5522        True
 5523        >>> g.getDestination('A', 'up')
 5524        1
 5525        >>> # Merging a transition with a reciprocal & tags
 5526        >>> g.addTransition('A', 'up2', 'B')
 5527        >>> g.addTransition('B', 'down2', 'A')
 5528        >>> g.setReciprocal('A', 'up2', 'down2')
 5529        >>> g.tagTransition('A', 'up2', 'one')
 5530        >>> g.tagTransition('B', 'down2', 'two')
 5531        >>> g.mergeTransitions('B', 'down2', 'down')
 5532        >>> g.getDestination('A', 'up2') is None
 5533        True
 5534        >>> g.getDestination('A', 'up')
 5535        1
 5536        >>> g.getDestination('B', 'down2') is None
 5537        True
 5538        >>> g.getDestination('B', 'down')
 5539        0
 5540        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
 5541        >>> g.addTransition('A', 'up2', 'B')
 5542        >>> g.setTransitionProperties(
 5543        ...     'A',
 5544        ...     'up2',
 5545        ...     requirement=base.ReqCapability('dash')
 5546        ... )
 5547        >>> g.setTransitionProperties('A', 'up',
 5548        ...     requirement=base.ReqCapability('slide'))
 5549        >>> g.mergeTransitions('A', 'up2', 'up')
 5550        >>> g.getDestination('A', 'up2') is None
 5551        True
 5552        >>> repr(g.getTransitionRequirement('A', 'up'))
 5553        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
 5554        >>> # Errors if destinations differ, or if something is missing
 5555        >>> g.mergeTransitions('A', 'down', 'up')
 5556        Traceback (most recent call last):
 5557        ...
 5558        exploration.core.MissingTransitionError...
 5559        >>> g.mergeTransitions('Z', 'one', 'two')
 5560        Traceback (most recent call last):
 5561        ...
 5562        exploration.core.MissingDecisionError...
 5563        >>> g.addDecision('C')
 5564        2
 5565        >>> g.addTransition('A', 'down', 'C')
 5566        >>> g.mergeTransitions('A', 'down', 'up')
 5567        Traceback (most recent call last):
 5568        ...
 5569        exploration.core.InvalidDestinationError...
 5570        >>> # Merging a reciprocal onto an edge that doesn't have one
 5571        >>> g.addTransition('A', 'down2', 'C')
 5572        >>> g.addTransition('C', 'up2', 'A')
 5573        >>> g.setReciprocal('A', 'down2', 'up2')
 5574        >>> g.tagTransition('C', 'up2', 'narrow')
 5575        >>> g.getReciprocal('A', 'down') is None
 5576        True
 5577        >>> g.mergeTransitions('A', 'down2', 'down')
 5578        >>> g.getDestination('A', 'down2') is None
 5579        True
 5580        >>> g.getDestination('A', 'down')
 5581        2
 5582        >>> g.getDestination('C', 'up2')
 5583        0
 5584        >>> g.getReciprocal('A', 'down')
 5585        'up2'
 5586        >>> g.getReciprocal('C', 'up2')
 5587        'down'
 5588        >>> g.transitionTags('C', 'up2')
 5589        {'narrow': 1}
 5590        >>> # Merging without a reciprocal
 5591        >>> g.addTransition('C', 'up', 'A')
 5592        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
 5593        >>> g.getDestination('C', 'up2') is None
 5594        True
 5595        >>> g.getDestination('C', 'up')
 5596        0
 5597        >>> g.transitionTags('C', 'up') # tag gets merged
 5598        {'narrow': 1}
 5599        >>> g.getDestination('A', 'down')
 5600        2
 5601        >>> g.getReciprocal('A', 'down') is None
 5602        True
 5603        >>> g.getReciprocal('C', 'up') is None
 5604        True
 5605        >>> # Merging w/ normal reciprocals
 5606        >>> g.addDecision('D')
 5607        3
 5608        >>> g.addDecision('E')
 5609        4
 5610        >>> g.addTransition('D', 'up', 'E', 'return')
 5611        >>> g.addTransition('E', 'down', 'D')
 5612        >>> g.mergeTransitions('E', 'return', 'down')
 5613        >>> g.getDestination('D', 'up')
 5614        4
 5615        >>> g.getDestination('E', 'down')
 5616        3
 5617        >>> g.getDestination('E', 'return') is None
 5618        True
 5619        >>> g.getReciprocal('D', 'up')
 5620        'down'
 5621        >>> g.getReciprocal('E', 'down')
 5622        'up'
 5623        >>> # Merging w/ weird reciprocals
 5624        >>> g.addTransition('E', 'return', 'D')
 5625        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
 5626        >>> g.getReciprocal('D', 'up')
 5627        'down'
 5628        >>> g.getReciprocal('E', 'down')
 5629        'up'
 5630        >>> g.getReciprocal('E', 'return') # shared
 5631        'up'
 5632        >>> g.mergeTransitions('E', 'return', 'down')
 5633        >>> g.getDestination('D', 'up')
 5634        4
 5635        >>> g.getDestination('E', 'down')
 5636        3
 5637        >>> g.getDestination('E', 'return') is None
 5638        True
 5639        >>> g.getReciprocal('D', 'up')
 5640        'down'
 5641        >>> g.getReciprocal('E', 'down')
 5642        'up'
 5643        """
 5644        fromID = self.resolveDecision(fromDecision)
 5645
 5646        # Short-circuit in the no-op case
 5647        if merge == mergeInto:
 5648            return
 5649
 5650        # These lines will raise a MissingDecisionError or
 5651        # MissingTransitionError if needed
 5652        dest1 = self.destination(fromID, merge)
 5653        dest2 = self.destination(fromID, mergeInto)
 5654
 5655        if dest1 != dest2:
 5656            raise InvalidDestinationError(
 5657                f"Cannot merge transition {merge!r} into transition"
 5658                f" {mergeInto!r} from decision"
 5659                f" {self.identityOf(fromDecision)} because their"
 5660                f" destinations are different ({self.identityOf(dest1)}"
 5661                f" and {self.identityOf(dest2)}).\nNote: you can use"
 5662                f" `retargetTransition` to change the destination of a"
 5663                f" transition."
 5664            )
 5665
 5666        # Find and the transition properties
 5667        props1 = self.getTransitionProperties(fromID, merge)
 5668        props2 = self.getTransitionProperties(fromID, mergeInto)
 5669        merged = mergeProperties(props1, props2)
 5670        # Note that this doesn't change the reciprocal:
 5671        self.setTransitionProperties(fromID, mergeInto, **merged)
 5672
 5673        # Merge the reciprocal properties if requested
 5674        # Get reciprocal to merge into
 5675        reciprocal = self.getReciprocal(fromID, mergeInto)
 5676        # Get reciprocal that needs cleaning up
 5677        altReciprocal = self.getReciprocal(fromID, merge)
 5678        # If the reciprocal to be merged actually already was the
 5679        # reciprocal to merge into, there's nothing to do here
 5680        if altReciprocal != reciprocal:
 5681            if not mergeReciprocal:
 5682                # In this case, we sever the reciprocal relationship if
 5683                # there is a reciprocal
 5684                if altReciprocal is not None:
 5685                    self.setReciprocal(dest1, altReciprocal, None)
 5686                    # By default setBoth takes care of the other half
 5687            else:
 5688                # In this case, we try to merge reciprocals
 5689                # If altReciprocal is None, we don't need to do anything
 5690                if altReciprocal is not None:
 5691                    # Was there already a reciprocal or not?
 5692                    if reciprocal is None:
 5693                        # altReciprocal becomes the new reciprocal and is
 5694                        # not deleted
 5695                        self.setReciprocal(
 5696                            fromID,
 5697                            mergeInto,
 5698                            altReciprocal
 5699                        )
 5700                    else:
 5701                        # merge reciprocal properties
 5702                        props1 = self.getTransitionProperties(
 5703                            dest1,
 5704                            altReciprocal
 5705                        )
 5706                        props2 = self.getTransitionProperties(
 5707                            dest2,
 5708                            reciprocal
 5709                        )
 5710                        merged = mergeProperties(props1, props2)
 5711                        self.setTransitionProperties(
 5712                            dest1,
 5713                            reciprocal,
 5714                            **merged
 5715                        )
 5716
 5717                        # delete the old reciprocal transition
 5718                        self.remove_edge(dest1, fromID, altReciprocal)
 5719
 5720        # Delete the old transition (reciprocal deletion/severance is
 5721        # handled above if necessary)
 5722        self.remove_edge(fromID, dest1, merge)
 5723
 5724    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
 5725        """
 5726        Returns `True` or `False` depending on whether or not the
 5727        specified decision has been confirmed. Uses the presence or
 5728        absence of the 'unconfirmed' tag to determine this.
 5729
 5730        Note: 'unconfirmed' is used instead of 'confirmed' so that large
 5731        graphs with many confirmed nodes will be smaller when saved.
 5732        """
 5733        dID = self.resolveDecision(decision)
 5734
 5735        return 'unconfirmed' not in self.nodes[dID]['tags']
 5736
 5737    def replaceUnconfirmed(
 5738        self,
 5739        fromDecision: base.AnyDecisionSpecifier,
 5740        transition: base.Transition,
 5741        connectTo: Optional[base.AnyDecisionSpecifier] = None,
 5742        reciprocal: Optional[base.Transition] = None,
 5743        requirement: Optional[base.Requirement] = None,
 5744        applyConsequence: Optional[base.Consequence] = None,
 5745        placeInZone: Optional[base.Zone] = None,
 5746        forceNew: bool = False,
 5747        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 5748        annotations: Optional[List[base.Annotation]] = None,
 5749        revRequires: Optional[base.Requirement] = None,
 5750        revConsequence: Optional[base.Consequence] = None,
 5751        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 5752        revAnnotations: Optional[List[base.Annotation]] = None,
 5753        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 5754        decisionAnnotations: Optional[List[base.Annotation]] = None
 5755    ) -> Tuple[
 5756        Dict[base.Transition, base.Transition],
 5757        Dict[base.Transition, base.Transition]
 5758    ]:
 5759        """
 5760        Given a decision and an edge name in that decision, where the
 5761        named edge leads to a decision with an unconfirmed exploration
 5762        state (see `isConfirmed`), renames the unexplored decision on
 5763        the other end of that edge using the given `connectTo` name, or
 5764        if a decision using that name already exists, merges the
 5765        unexplored decision into that decision. If `connectTo` is a
 5766        `DecisionSpecifier` whose target doesn't exist, it will be
 5767        treated as just a name, but if it's an ID and it doesn't exist,
 5768        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
 5769        a reciprocal edge will be added using that name connecting the
 5770        `connectTo` decision back to the original decision. If this
 5771        transition already exists, it must also point to a node which is
 5772        also unexplored, and which will also be merged into the
 5773        `fromDecision` node.
 5774
 5775        If `connectTo` is not given (or is set to `None` explicitly)
 5776        then the name of the unexplored decision will not be changed,
 5777        unless that name has the form `'_u.-n-'` where `-n-` is a positive
 5778        integer (i.e., the form given to automatically-named unknown
 5779        nodes). In that case, the name will be changed to `'_x.-n-'` using
 5780        the same number, or a higher number if that name is already taken.
 5781
 5782        If the destination is being renamed or if the destination's
 5783        exploration state counts as unexplored, the exploration state of
 5784        the destination will be set to 'exploring'.
 5785
 5786        If a `placeInZone` is specified, the destination will be placed
 5787        directly into that zone (even if it already existed and has zone
 5788        information), and it will be removed from any other zones it had
 5789        been a direct member of. If `placeInZone` is set to
 5790        `base.DefaultZone`, then the destination will be placed into
 5791        each zone which is a direct parent of the origin, but only if
 5792        the destination is not an already-explored existing decision AND
 5793        it is not already in any zones (in those cases no zone changes
 5794        are made). This will also remove it from any previous zones it
 5795        had been a part of. If `placeInZone` is left as `None` (the
 5796        default) no zone changes are made.
 5797
 5798        If `placeInZone` is specified and that zone didn't already exist,
 5799        it will be created as a new level-0 zone and will be added as a
 5800        sub-zone of each zone that's a direct parent of any level-0 zone
 5801        that the origin is a member of.
 5802
 5803        If `forceNew` is specified, then the destination will just be
 5804        renamed, even if another decision with the same name already
 5805        exists. It's an error to use `forceNew` with a decision ID as
 5806        the destination.
 5807
 5808        Any additional edges pointing to or from the unknown node(s)
 5809        being replaced will also be re-targeted at the now-discovered
 5810        known destination(s) if necessary. These edges will retain their
 5811        reciprocal names, or if this would cause a name clash, they will
 5812        be renamed with a suffix (see `retargetTransition`).
 5813
 5814        The return value is a pair of dictionaries mapping old names to
 5815        new ones that just includes the names which were changed. The
 5816        first dictionary contains renamed transitions that are outgoing
 5817        from the new destination node (which used to be outgoing from
 5818        the unexplored node). The second dictionary contains renamed
 5819        transitions that are outgoing from the source node (which used
 5820        to be outgoing from the unexplored node attached to the
 5821        reciprocal transition; if there was no reciprocal transition
 5822        specified then this will always be an empty dictionary).
 5823
 5824        An `ExplorationStatusError` will be raised if the destination
 5825        of the specified transition counts as visited (see
 5826        `hasBeenVisited`). An `ExplorationStatusError` will also be
 5827        raised if the `connectTo`'s `reciprocal` transition does not lead
 5828        to an unconfirmed decision (it's okay if this second transition
 5829        doesn't exist). A `TransitionCollisionError` will be raised if
 5830        the unconfirmed destination decision already has an outgoing
 5831        transition with the specified `reciprocal` which does not lead
 5832        back to the `fromDecision`.
 5833
 5834        The transition properties (requirement, consequences, tags,
 5835        and/or annotations) of the replaced transition will be copied
 5836        over to the new transition. Transition properties from the
 5837        reciprocal transition will also be copied for the newly created
 5838        reciprocal edge. Properties for any additional edges to/from the
 5839        unknown node will also be copied.
 5840
 5841        Also, any transition properties on existing forward or reciprocal
 5842        edges from the destination node with the indicated reverse name
 5843        will be merged with those from the target transition. Note that
 5844        this merging process may introduce corruption of complex
 5845        transition consequences. TODO: Fix that!
 5846
 5847        Any tags and annotations are added to copied tags/annotations,
 5848        but specified requirements, and/or consequences will replace
 5849        previous requirements/consequences, rather than being added to
 5850        them.
 5851
 5852        ## Example
 5853
 5854        >>> g = DecisionGraph()
 5855        >>> g.addDecision('A')
 5856        0
 5857        >>> g.addUnexploredEdge('A', 'up')
 5858        1
 5859        >>> g.destination('A', 'up')
 5860        1
 5861        >>> g.destination('_u.0', 'return')
 5862        0
 5863        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
 5864        ({}, {})
 5865        >>> g.destination('A', 'up')
 5866        1
 5867        >>> g.nameFor(1)
 5868        'B'
 5869        >>> g.destination('B', 'down')
 5870        0
 5871        >>> g.getDestination('B', 'return') is None
 5872        True
 5873        >>> '_u.0' in g.nameLookup
 5874        False
 5875        >>> g.getReciprocal('A', 'up')
 5876        'down'
 5877        >>> g.getReciprocal('B', 'down')
 5878        'up'
 5879        >>> # Two unexplored edges to the same node:
 5880        >>> g.addDecision('C')
 5881        2
 5882        >>> g.addTransition('B', 'next', 'C')
 5883        >>> g.addTransition('C', 'prev', 'B')
 5884        >>> g.setReciprocal('B', 'next', 'prev')
 5885        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
 5886        3
 5887        >>> g.addTransition('C', 'down', 'D')
 5888        >>> g.addTransition('D', 'up', 'C')
 5889        >>> g.setReciprocal('C', 'down', 'up')
 5890        >>> g.replaceUnconfirmed('C', 'down')
 5891        ({}, {})
 5892        >>> g.destination('C', 'down')
 5893        3
 5894        >>> g.destination('A', 'next')
 5895        3
 5896        >>> g.destinationsFrom('D')
 5897        {'prev': 0, 'up': 2}
 5898        >>> g.decisionTags('D')
 5899        {}
 5900        >>> # An unexplored transition which turns out to connect to a
 5901        >>> # known decision, with name collisions
 5902        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
 5903        4
 5904        >>> g.tagDecision('_u.2', 'wet')
 5905        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
 5906        Traceback (most recent call last):
 5907        ...
 5908        exploration.core.TransitionCollisionError...
 5909        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
 5910        5
 5911        >>> g.tagDecision('_u.3', 'dry')
 5912        >>> # Add transitions that will collide when merged
 5913        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
 5914        6
 5915        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
 5916        7
 5917        >>> g.getReciprocal('A', 'prev')
 5918        'next'
 5919        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
 5920        ({'prev': 'prev.1'}, {'up': 'up.1'})
 5921        >>> g.destination('A', 'prev')
 5922        3
 5923        >>> g.destination('D', 'next')
 5924        0
 5925        >>> g.getReciprocal('A', 'prev')
 5926        'next'
 5927        >>> g.getReciprocal('D', 'next')
 5928        'prev'
 5929        >>> # Note that further unexplored structures are NOT merged
 5930        >>> # even if they match against existing structures...
 5931        >>> g.destination('A', 'up.1')
 5932        6
 5933        >>> g.destination('D', 'prev.1')
 5934        7
 5935        >>> '_u.2' in g.nameLookup
 5936        False
 5937        >>> '_u.3' in g.nameLookup
 5938        False
 5939        >>> g.decisionTags('D') # tags are merged
 5940        {'dry': 1}
 5941        >>> g.decisionTags('A')
 5942        {'wet': 1}
 5943        >>> # Auto-renaming an anonymous unexplored node
 5944        >>> g.addUnexploredEdge('B', 'out')
 5945        8
 5946        >>> g.replaceUnconfirmed('B', 'out')
 5947        ({}, {})
 5948        >>> '_u.6' in g
 5949        False
 5950        >>> g.destination('B', 'out')
 5951        8
 5952        >>> g.nameFor(8)
 5953        '_x.6'
 5954        >>> g.destination('_x.6', 'return')
 5955        1
 5956        >>> # Placing a node into a zone
 5957        >>> g.addUnexploredEdge('B', 'through')
 5958        9
 5959        >>> g.getDecision('E') is None
 5960        True
 5961        >>> g.replaceUnconfirmed(
 5962        ...     'B',
 5963        ...     'through',
 5964        ...     'E',
 5965        ...     'back',
 5966        ...     placeInZone='Zone'
 5967        ... )
 5968        ({}, {})
 5969        >>> g.getDecision('E')
 5970        9
 5971        >>> g.destination('B', 'through')
 5972        9
 5973        >>> g.destination('E', 'back')
 5974        1
 5975        >>> g.zoneParents(9)
 5976        {'Zone'}
 5977        >>> g.addUnexploredEdge('E', 'farther')
 5978        10
 5979        >>> g.replaceUnconfirmed(
 5980        ...     'E',
 5981        ...     'farther',
 5982        ...     'F',
 5983        ...     'closer',
 5984        ...     placeInZone=base.DefaultZone
 5985        ... )
 5986        ({}, {})
 5987        >>> g.destination('E', 'farther')
 5988        10
 5989        >>> g.destination('F', 'closer')
 5990        9
 5991        >>> g.zoneParents(10)
 5992        {'Zone'}
 5993        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
 5994        11
 5995        >>> g.replaceUnconfirmed(
 5996        ...     'F',
 5997        ...     'backwards',
 5998        ...     'G',
 5999        ...     'forwards',
 6000        ...     placeInZone=base.DefaultZone
 6001        ... )
 6002        ({}, {})
 6003        >>> g.destination('F', 'backwards')
 6004        11
 6005        >>> g.destination('G', 'forwards')
 6006        10
 6007        >>> g.zoneParents(11)  # not changed since it already had a zone
 6008        {'Enoz'}
 6009        >>> # TODO: forceNew example
 6010        """
 6011
 6012        # Defaults
 6013        if tags is None:
 6014            tags = {}
 6015        if annotations is None:
 6016            annotations = []
 6017        if revTags is None:
 6018            revTags = {}
 6019        if revAnnotations is None:
 6020            revAnnotations = []
 6021        if decisionTags is None:
 6022            decisionTags = {}
 6023        if decisionAnnotations is None:
 6024            decisionAnnotations = []
 6025
 6026        # Resolve source
 6027        fromID = self.resolveDecision(fromDecision)
 6028
 6029        # Figure out destination decision
 6030        oldUnexplored = self.destination(fromID, transition)
 6031        if self.isConfirmed(oldUnexplored):
 6032            raise ExplorationStatusError(
 6033                f"Transition {transition!r} from"
 6034                f" {self.identityOf(fromDecision)} does not lead to an"
 6035                f" unconfirmed decision (it leads to"
 6036                f" {self.identityOf(oldUnexplored)} which is not tagged"
 6037                f" 'unconfirmed')."
 6038            )
 6039
 6040        # Resolve destination
 6041        newName: Optional[base.DecisionName] = None
 6042        connectID: Optional[base.DecisionID] = None
 6043        if forceNew:
 6044            if isinstance(connectTo, base.DecisionID):
 6045                raise TypeError(
 6046                    f"connectTo cannot be a decision ID when forceNew"
 6047                    f" is True. Got: {self.identityOf(connectTo)}"
 6048                )
 6049            elif isinstance(connectTo, base.DecisionSpecifier):
 6050                newName = connectTo.name
 6051            elif isinstance(connectTo, base.DecisionName):
 6052                newName = connectTo
 6053            elif connectTo is None:
 6054                oldName = self.nameFor(oldUnexplored)
 6055                if (
 6056                    oldName.startswith('_u.')
 6057                and oldName[3:].isdigit()
 6058                ):
 6059                    newName = utils.uniqueName('_x.' + oldName[3:], self)
 6060                else:
 6061                    newName = oldName
 6062            else:
 6063                raise TypeError(
 6064                    f"Invalid connectTo value: {connectTo!r}"
 6065                )
 6066        elif connectTo is not None:
 6067            try:
 6068                connectID = self.resolveDecision(connectTo)
 6069                # leave newName as None
 6070            except MissingDecisionError:
 6071                if isinstance(connectTo, int):
 6072                    raise
 6073                elif isinstance(connectTo, base.DecisionSpecifier):
 6074                    newName = connectTo.name
 6075                    # The domain & zone are ignored here
 6076                else:  # Must just be a string
 6077                    assert isinstance(connectTo, str)
 6078                    newName = connectTo
 6079        else:
 6080            # If connectTo name wasn't specified, use current name of
 6081            # unknown node unless it's a default name
 6082            oldName = self.nameFor(oldUnexplored)
 6083            if (
 6084                oldName.startswith('_u.')
 6085            and oldName[3:].isdigit()
 6086            ):
 6087                newName = utils.uniqueName('_x.' + oldName[3:], self)
 6088            else:
 6089                newName = oldName
 6090
 6091        # One or the other should be valid at this point
 6092        assert connectID is not None or newName is not None
 6093
 6094        # Check that the old unknown doesn't have a reciprocal edge that
 6095        # would collide with the specified return edge
 6096        if reciprocal is not None:
 6097            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
 6098            if revFromUnknown not in (None, fromID):
 6099                raise TransitionCollisionError(
 6100                    f"Transition {reciprocal!r} from"
 6101                    f" {self.identityOf(oldUnexplored)} exists and does"
 6102                    f" not lead back to {self.identityOf(fromDecision)}"
 6103                    f" (it leads to {self.identityOf(revFromUnknown)})."
 6104                )
 6105
 6106        # Remember old reciprocal edge for future merging in case
 6107        # it's not reciprocal
 6108        oldReciprocal = self.getReciprocal(fromID, transition)
 6109
 6110        # Apply any new tags or annotations, or create a new node
 6111        needsZoneInfo = False
 6112        if connectID is not None:
 6113            # Before applying tags, check if we need to error out
 6114            # because of a reciprocal edge that points to a known
 6115            # destination:
 6116            if reciprocal is not None:
 6117                otherOldUnknown: Optional[
 6118                    base.DecisionID
 6119                ] = self.getDestination(
 6120                    connectID,
 6121                    reciprocal
 6122                )
 6123                if (
 6124                    otherOldUnknown is not None
 6125                and self.isConfirmed(otherOldUnknown)
 6126                ):
 6127                    raise ExplorationStatusError(
 6128                        f"Reciprocal transition {reciprocal!r} from"
 6129                        f" {self.identityOf(connectTo)} does not lead"
 6130                        f" to an unconfirmed decision (it leads to"
 6131                        f" {self.identityOf(otherOldUnknown)})."
 6132                    )
 6133            self.tagDecision(connectID, decisionTags)
 6134            self.annotateDecision(connectID, decisionAnnotations)
 6135            # Still needs zone info if the place we're connecting to was
 6136            # unconfirmed up until now, since unconfirmed nodes don't
 6137            # normally get zone info when they're created.
 6138            if not self.isConfirmed(connectID):
 6139                needsZoneInfo = True
 6140
 6141            # First, merge the old unknown with the connectTo node...
 6142            destRenames = self.mergeDecisions(
 6143                oldUnexplored,
 6144                connectID,
 6145                errorOnNameColision=False
 6146            )
 6147        else:
 6148            needsZoneInfo = True
 6149            if len(self.zoneParents(oldUnexplored)) > 0:
 6150                needsZoneInfo = False
 6151            assert newName is not None
 6152            self.renameDecision(oldUnexplored, newName)
 6153            connectID = oldUnexplored
 6154            # In this case there can't be an other old unknown
 6155            otherOldUnknown = None
 6156            destRenames = {}  # empty
 6157
 6158        # Check for domain mismatch to stifle zone updates:
 6159        fromDomain = self.domainFor(fromID)
 6160        if connectID is None:
 6161            destDomain = self.domainFor(oldUnexplored)
 6162        else:
 6163            destDomain = self.domainFor(connectID)
 6164
 6165        # Stifle zone updates if there's a mismatch
 6166        if fromDomain != destDomain:
 6167            needsZoneInfo = False
 6168
 6169        # Records renames that happen at the source (from node)
 6170        sourceRenames = {}  # empty for now
 6171
 6172        assert connectID is not None
 6173
 6174        # Apply the new zone if there is one
 6175        if placeInZone is not None:
 6176            if placeInZone == base.DefaultZone:
 6177                # When using DefaultZone, changes are only made for new
 6178                # destinations which don't already have any zones and
 6179                # which are in the same domain as the departing node:
 6180                # they get placed into each zone parent of the source
 6181                # decision.
 6182                if needsZoneInfo:
 6183                    # Remove destination from all current parents
 6184                    removeFrom = set(self.zoneParents(connectID))  # copy
 6185                    for parent in removeFrom:
 6186                        self.removeDecisionFromZone(connectID, parent)
 6187                    # Add it to parents of origin
 6188                    for parent in self.zoneParents(fromID):
 6189                        self.addDecisionToZone(connectID, parent)
 6190            else:
 6191                placeInZone = cast(base.Zone, placeInZone)
 6192                # Create the zone if it doesn't already exist
 6193                if self.getZoneInfo(placeInZone) is None:
 6194                    self.createZone(placeInZone, 0)
 6195                    # Add it to each grandparent of the from decision
 6196                    for parent in self.zoneParents(fromID):
 6197                        for grandparent in self.zoneParents(parent):
 6198                            self.addZoneToZone(placeInZone, grandparent)
 6199                # Remove destination from all current parents
 6200                for parent in set(self.zoneParents(connectID)):
 6201                    self.removeDecisionFromZone(connectID, parent)
 6202                # Add it to the specified zone
 6203                self.addDecisionToZone(connectID, placeInZone)
 6204
 6205        # Next, if there is a reciprocal name specified, we do more...
 6206        if reciprocal is not None:
 6207            # Figure out what kind of merging needs to happen
 6208            if otherOldUnknown is None:
 6209                if revFromUnknown is None:
 6210                    # Just create the desired reciprocal transition, which
 6211                    # we know does not already exist
 6212                    self.addTransition(connectID, reciprocal, fromID)
 6213                    otherOldReciprocal = None
 6214                else:
 6215                    # Reciprocal exists, as revFromUnknown
 6216                    otherOldReciprocal = None
 6217            else:
 6218                otherOldReciprocal = self.getReciprocal(
 6219                    connectID,
 6220                    reciprocal
 6221                )
 6222                # we need to merge otherOldUnknown into our fromDecision
 6223                sourceRenames = self.mergeDecisions(
 6224                    otherOldUnknown,
 6225                    fromID,
 6226                    errorOnNameColision=False
 6227                )
 6228                # Unvisited tag after merge only if both were
 6229
 6230            # No matter what happened we ensure the reciprocal
 6231            # relationship is set up:
 6232            self.setReciprocal(fromID, transition, reciprocal)
 6233
 6234            # Now we might need to merge some transitions:
 6235            # - Any reciprocal of the target transition should be merged
 6236            #   with reciprocal (if it was already reciprocal, that's a
 6237            #   no-op).
 6238            # - Any reciprocal of the reciprocal transition from the target
 6239            #   node (leading to otherOldUnknown) should be merged with
 6240            #   the target transition, even if it shared a name and was
 6241            #   renamed as a result.
 6242            # - If reciprocal was renamed during the initial merge, those
 6243            #   transitions should be merged.
 6244
 6245            # Merge old reciprocal into reciprocal
 6246            if oldReciprocal is not None:
 6247                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
 6248                if self.getDestination(connectID, oldRev) is not None:
 6249                    # Note that we don't want to auto-merge the reciprocal,
 6250                    # which is the target transition
 6251                    self.mergeTransitions(
 6252                        connectID,
 6253                        oldRev,
 6254                        reciprocal,
 6255                        mergeReciprocal=False
 6256                    )
 6257                    # Remove it from the renames map
 6258                    if oldReciprocal in destRenames:
 6259                        del destRenames[oldReciprocal]
 6260
 6261            # Merge reciprocal reciprocal from otherOldUnknown
 6262            if otherOldReciprocal is not None:
 6263                otherOldRev = sourceRenames.get(
 6264                    otherOldReciprocal,
 6265                    otherOldReciprocal
 6266                )
 6267                # Note that the reciprocal is reciprocal, which we don't
 6268                # need to merge
 6269                self.mergeTransitions(
 6270                    fromID,
 6271                    otherOldRev,
 6272                    transition,
 6273                    mergeReciprocal=False
 6274                )
 6275                # Remove it from the renames map
 6276                if otherOldReciprocal in sourceRenames:
 6277                    del sourceRenames[otherOldReciprocal]
 6278
 6279            # Merge any renamed reciprocal onto reciprocal
 6280            if reciprocal in destRenames:
 6281                extraRev = destRenames[reciprocal]
 6282                self.mergeTransitions(
 6283                    connectID,
 6284                    extraRev,
 6285                    reciprocal,
 6286                    mergeReciprocal=False
 6287                )
 6288                # Remove it from the renames map
 6289                del destRenames[reciprocal]
 6290
 6291        # Accumulate new tags & annotations for the transitions
 6292        self.tagTransition(fromID, transition, tags)
 6293        self.annotateTransition(fromID, transition, annotations)
 6294
 6295        if reciprocal is not None:
 6296            self.tagTransition(connectID, reciprocal, revTags)
 6297            self.annotateTransition(connectID, reciprocal, revAnnotations)
 6298
 6299        # Override copied requirement/consequences for the transitions
 6300        if requirement is not None:
 6301            self.setTransitionRequirement(
 6302                fromID,
 6303                transition,
 6304                requirement
 6305            )
 6306        if applyConsequence is not None:
 6307            self.setConsequence(
 6308                fromID,
 6309                transition,
 6310                applyConsequence
 6311            )
 6312
 6313        if reciprocal is not None:
 6314            if revRequires is not None:
 6315                self.setTransitionRequirement(
 6316                    connectID,
 6317                    reciprocal,
 6318                    revRequires
 6319                )
 6320            if revConsequence is not None:
 6321                self.setConsequence(
 6322                    connectID,
 6323                    reciprocal,
 6324                    revConsequence
 6325                )
 6326
 6327        # Remove 'unconfirmed' tag if it was present
 6328        self.untagDecision(connectID, 'unconfirmed')
 6329
 6330        # Final checks
 6331        assert self.getDestination(fromDecision, transition) == connectID
 6332        useConnect: base.AnyDecisionSpecifier
 6333        useRev: Optional[str]
 6334        if connectTo is None:
 6335            useConnect = connectID
 6336        else:
 6337            useConnect = connectTo
 6338        if reciprocal is None:
 6339            useRev = self.getReciprocal(fromDecision, transition)
 6340        else:
 6341            useRev = reciprocal
 6342        if useRev is not None:
 6343            try:
 6344                assert self.getDestination(useConnect, useRev) == fromID
 6345            except AmbiguousDecisionSpecifierError:
 6346                assert self.getDestination(connectID, useRev) == fromID
 6347
 6348        # Return our final rename dictionaries
 6349        return (destRenames, sourceRenames)
 6350
 6351    def endingID(self, name: base.DecisionName) -> base.DecisionID:
 6352        """
 6353        Returns the decision ID for the ending with the specified name.
 6354        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
 6355        don't normally include any zone information. If no ending with
 6356        the specified name already existed, then a new ending with that
 6357        name will be created and its Decision ID will be returned.
 6358
 6359        If a new decision is created, it will be tagged as unconfirmed.
 6360
 6361        Note that endings mostly aren't special: they're normal
 6362        decisions in a separate singular-focalized domain. However, some
 6363        parts of the exploration and journal machinery treat them
 6364        differently (in particular, taking certain actions via
 6365        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
 6366        active is an error.
 6367        """
 6368        # Create our new ending decision if we need to
 6369        try:
 6370            endID = self.resolveDecision(
 6371                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
 6372            )
 6373        except MissingDecisionError:
 6374            # Create a new decision for the ending
 6375            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
 6376            # Tag it as unconfirmed
 6377            self.tagDecision(endID, 'unconfirmed')
 6378
 6379        return endID
 6380
 6381    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
 6382        """
 6383        Given the name of a trigger group, returns the ID of the special
 6384        node representing that trigger group in the `TRIGGERS_DOMAIN`.
 6385        If the specified group didn't already exist, it will be created.
 6386
 6387        Trigger group decisions are not special: they just exist in a
 6388        separate spreading-focalized domain and have a few API methods to
 6389        access them, but all the normal decision-related API methods
 6390        still work. Their intended use is for sets of global triggers,
 6391        by attaching actions with the 'trigger' tag to them and then
 6392        activating or deactivating them as needed.
 6393        """
 6394        result = self.getDecision(
 6395            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 6396        )
 6397        if result is None:
 6398            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
 6399        else:
 6400            return result
 6401
 6402    @staticmethod
 6403    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
 6404        """
 6405        Returns one of a number of example decision graphs, depending on
 6406        the string given. It returns a fresh copy each time. The graphs
 6407        are:
 6408
 6409        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
 6410            and 2, each connected to the next in the sequence by a
 6411            'next' transition with reciprocal 'prev'. In other words, a
 6412            simple little triangle. There are no tags, annotations,
 6413            requirements, consequences, mechanisms, or equivalences.
 6414        - 'abc': A more complicated 3-node setup that introduces a
 6415            little bit of everything. In this graph, we have the same
 6416            three nodes, but different transitions:
 6417
 6418                * From A you can go 'left' to B with reciprocal 'right'.
 6419                * From A you can also go 'up_left' to B with reciprocal
 6420                    'up_right'. These transitions both require the
 6421                    'grate' mechanism (which is at decision A) to be in
 6422                    state 'open'.
 6423                * From A you can go 'down' to C with reciprocal 'up'.
 6424
 6425            (In this graph, B and C are not directly connected to each
 6426            other.)
 6427
 6428            The graph has two level-0 zones 'zoneA' and 'zoneB', along
 6429            with a level-1 zone 'upZone'. Decisions A and C are in
 6430            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
 6431            not.
 6432
 6433            The decision A has annotation:
 6434
 6435                'This is a multi-word "annotation."'
 6436
 6437            The transition 'down' from A has annotation:
 6438
 6439                "Transition 'annotation.'"
 6440
 6441            Decision B has tags 'b' with value 1 and 'tag2' with value
 6442            '"value"'.
 6443
 6444            Decision C has tag 'aw"ful' with value "ha'ha'".
 6445
 6446            Transition 'up' from C has tag 'fast' with value 1.
 6447
 6448            At decision C there are actions 'grab_helmet' and
 6449            'pull_lever'.
 6450
 6451            The 'grab_helmet' transition requires that you don't have
 6452            the 'helmet' capability, and gives you that capability,
 6453            deactivating with delay 3.
 6454
 6455            The 'pull_lever' transition requires that you do have the
 6456            'helmet' capability, and takes away that capability, but it
 6457            also gives you 1 token, and if you have 2 tokens (before
 6458            getting the one extra), it sets the 'grate' mechanism (which
 6459            is a decision A) to state 'open' and deactivates.
 6460
 6461            The graph has an equivalence: having the 'helmet' capability
 6462            satisfies requirements for the 'grate' mechanism to be in the
 6463            'open' state.
 6464
 6465        """
 6466        result = DecisionGraph()
 6467        if which == 'simple':
 6468            result.addDecision('A')  # id 0
 6469            result.addDecision('B')  # id 1
 6470            result.addDecision('C')  # id 2
 6471            result.addTransition('A', 'next', 'B', 'prev')
 6472            result.addTransition('B', 'next', 'C', 'prev')
 6473            result.addTransition('C', 'next', 'A', 'prev')
 6474        elif which == 'abc':
 6475            result.addDecision('A')  # id 0
 6476            result.addDecision('B')  # id 1
 6477            result.addDecision('C')  # id 2
 6478            result.createZone('zoneA', 0)
 6479            result.createZone('zoneB', 0)
 6480            result.createZone('upZone', 1)
 6481            result.addZoneToZone('zoneA', 'upZone')
 6482            result.addDecisionToZone('A', 'zoneA')
 6483            result.addDecisionToZone('B', 'zoneB')
 6484            result.addDecisionToZone('C', 'zoneA')
 6485            result.addTransition('A', 'left', 'B', 'right')
 6486            result.addTransition('A', 'up_left', 'B', 'up_right')
 6487            result.addTransition('A', 'down', 'C', 'up')
 6488            result.setTransitionRequirement(
 6489                'A',
 6490                'up_left',
 6491                base.ReqMechanism('grate', 'open')
 6492            )
 6493            result.setTransitionRequirement(
 6494                'B',
 6495                'up_right',
 6496                base.ReqMechanism('grate', 'open')
 6497            )
 6498            result.annotateDecision('A', 'This is a multi-word "annotation."')
 6499            result.annotateTransition('A', 'down', "Transition 'annotation.'")
 6500            result.tagDecision('B', 'b')
 6501            result.tagDecision('B', 'tag2', '"value"')
 6502            result.tagDecision('C', 'aw"ful', "ha'ha")
 6503            result.tagTransition('C', 'up', 'fast')
 6504            result.addMechanism('grate', 'A')
 6505            result.addAction(
 6506                'C',
 6507                'grab_helmet',
 6508                base.ReqNot(base.ReqCapability('helmet')),
 6509                [
 6510                    base.effect(gain='helmet'),
 6511                    base.effect(deactivate=True, delay=3)
 6512                ]
 6513            )
 6514            result.addAction(
 6515                'C',
 6516                'pull_lever',
 6517                base.ReqCapability('helmet'),
 6518                [
 6519                    base.effect(lose='helmet'),
 6520                    base.effect(gain=('token', 1)),
 6521                    base.condition(
 6522                        base.ReqTokens('token', 2),
 6523                        [
 6524                            base.effect(set=('grate', 'open')),
 6525                            base.effect(deactivate=True)
 6526                        ]
 6527                    )
 6528                ]
 6529            )
 6530            result.addEquivalence(
 6531                base.ReqCapability('helmet'),
 6532                (0, 'open')
 6533            )
 6534        else:
 6535            raise ValueError(f"Invalid example name: {which!r}")
 6536
 6537        return result
 6538
 6539
 6540#---------------------------#
 6541# DiscreteExploration class #
 6542#---------------------------#
 6543
 6544def emptySituation() -> base.Situation:
 6545    """
 6546    Creates and returns an empty situation: A situation that has an
 6547    empty `DecisionGraph`, an empty `State`, a 'pending' decision type
 6548    with `None` as the action taken, no tags, and no annotations.
 6549    """
 6550    return base.Situation(
 6551        graph=DecisionGraph(),
 6552        state=base.emptyState(),
 6553        type='pending',
 6554        action=None,
 6555        saves={},
 6556        tags={},
 6557        annotations=[]
 6558    )
 6559
 6560
 6561class DiscreteExploration:
 6562    """
 6563    A list of `Situations` each of which contains a `DecisionGraph`
 6564    representing exploration over time, with `States` containing
 6565    `FocalContext` information for each step and 'taken' values for the
 6566    transition selected (at a particular decision) in that step. Each
 6567    decision graph represents a new state of the world (and/or new
 6568    knowledge about a persisting state of the world), and the 'taken'
 6569    transition in one situation transition indicates which option was
 6570    selected, or what event happened to cause update(s). Depending on the
 6571    resolution, it could represent a close record of every decision made
 6572    or a more coarse set of snapshots from gameplay with more time in
 6573    between.
 6574
 6575    The steps of the exploration can also be tagged and annotated (see
 6576    `tagStep` and `annotateStep`).
 6577
 6578    It also holds a `layouts` field that includes zero or more
 6579    `base.Layout`s by name.
 6580
 6581    When a new `DiscreteExploration` is created, it starts out with an
 6582    empty `Situation` that contains an empty `DecisionGraph`. Use the
 6583    `start` method to name the starting decision point and set things up
 6584    for other methods.
 6585
 6586    Tracking of player goals and destinations is also planned (see the
 6587    `quest`, `progress`, `complete`, `destination`, and `arrive` methods).
 6588    TODO: That
 6589    """
 6590    def __init__(self) -> None:
 6591        self.situations: List[base.Situation] = [
 6592            base.Situation(
 6593                graph=DecisionGraph(),
 6594                state=base.emptyState(),
 6595                type='pending',
 6596                action=None,
 6597                saves={},
 6598                tags={},
 6599                annotations=[]
 6600            )
 6601        ]
 6602        self.layouts: Dict[str, base.Layout] = {}
 6603
 6604    # Note: not hashable
 6605
 6606    def __eq__(self, other):
 6607        """
 6608        Equality checker. `DiscreteExploration`s can only be equal to
 6609        other `DiscreteExploration`s, not to other kinds of things.
 6610        """
 6611        if not isinstance(other, DiscreteExploration):
 6612            return False
 6613        else:
 6614            return self.situations == other.situations
 6615
 6616    @staticmethod
 6617    def fromGraph(
 6618        graph: DecisionGraph,
 6619        state: Optional[base.State] = None
 6620    ) -> 'DiscreteExploration':
 6621        """
 6622        Creates an exploration which has just a single step whose graph
 6623        is the entire specified graph, with the specified decision as
 6624        the primary decision (if any). The graph is copied, so that
 6625        changes to the exploration will not modify it. A starting state
 6626        may also be specified if desired, although if not an empty state
 6627        will be used (a provided starting state is NOT copied, but used
 6628        directly).
 6629
 6630        Example:
 6631
 6632        >>> g = DecisionGraph()
 6633        >>> g.addDecision('Room1')
 6634        0
 6635        >>> g.addDecision('Room2')
 6636        1
 6637        >>> g.addTransition('Room1', 'door', 'Room2', 'door')
 6638        >>> e = DiscreteExploration.fromGraph(g)
 6639        >>> len(e)
 6640        1
 6641        >>> e.getSituation().graph == g
 6642        True
 6643        >>> e.getActiveDecisions()
 6644        set()
 6645        >>> e.primaryDecision() is None
 6646        True
 6647        >>> e.observe('Room1', 'hatch')
 6648        2
 6649        >>> e.getSituation().graph == g
 6650        False
 6651        >>> e.getSituation().graph.destinationsFrom('Room1')
 6652        {'door': 1, 'hatch': 2}
 6653        >>> g.destinationsFrom('Room1')
 6654        {'door': 1}
 6655        """
 6656        result = DiscreteExploration()
 6657        result.situations[0] = base.Situation(
 6658            graph=copy.deepcopy(graph),
 6659            state=base.emptyState() if state is None else state,
 6660            type='pending',
 6661            action=None,
 6662            saves={},
 6663            tags={},
 6664            annotations=[]
 6665        )
 6666        return result
 6667
 6668    def __len__(self) -> int:
 6669        """
 6670        The 'length' of an exploration is the number of steps.
 6671        """
 6672        return len(self.situations)
 6673
 6674    def __getitem__(self, i: int) -> base.Situation:
 6675        """
 6676        Indexing an exploration returns the situation at that step.
 6677        """
 6678        return self.situations[i]
 6679
 6680    def __iter__(self) -> Iterator[base.Situation]:
 6681        """
 6682        Iterating over an exploration yields each `Situation` in order.
 6683        """
 6684        for i in range(len(self)):
 6685            yield self[i]
 6686
 6687    def getSituation(self, step: int = -1) -> base.Situation:
 6688        """
 6689        Returns a `base.Situation` named tuple detailing the state of
 6690        the exploration at a given step (or at the current step if no
 6691        argument is given). Note that this method works the same
 6692        way as indexing the exploration: see `__getitem__`.
 6693
 6694        Raises an `IndexError` if asked for a step that's out-of-range.
 6695        """
 6696        return self[step]
 6697
 6698    def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]:
 6699        """
 6700        Returns the current primary `base.DecisionID`, or the primary
 6701        decision from a specific step if one is specified. This may be
 6702        `None` for some steps, but mostly it's the destination of the
 6703        transition taken in the previous step.
 6704        """
 6705        return self[step].state['primaryDecision']
 6706
 6707    def effectiveCapabilities(
 6708        self,
 6709        step: int = -1
 6710    ) -> base.CapabilitySet:
 6711        """
 6712        Returns the effective capability set for the specified step
 6713        (default is the last/current step). See
 6714        `base.effectiveCapabilities`.
 6715        """
 6716        return base.effectiveCapabilitySet(self.getSituation(step).state)
 6717
 6718    def getCommonContext(
 6719        self,
 6720        step: Optional[int] = None
 6721    ) -> base.FocalContext:
 6722        """
 6723        Returns the common `FocalContext` at the specified step, or at
 6724        the current step if no argument is given. Raises an `IndexError`
 6725        if an invalid step is specified.
 6726        """
 6727        if step is None:
 6728            step = -1
 6729        state = self.getSituation(step).state
 6730        return state['common']
 6731
 6732    def getActiveContext(
 6733        self,
 6734        step: Optional[int] = None
 6735    ) -> base.FocalContext:
 6736        """
 6737        Returns the active `FocalContext` at the specified step, or at
 6738        the current step if no argument is provided. Raises an
 6739        `IndexError` if an invalid step is specified.
 6740        """
 6741        if step is None:
 6742            step = -1
 6743        state = self.getSituation(step).state
 6744        return state['contexts'][state['activeContext']]
 6745
 6746    def addFocalContext(self, name: base.FocalContextName) -> None:
 6747        """
 6748        Adds a new empty focal context to our set of focal contexts (see
 6749        `emptyFocalContext`). Use `setActiveContext` to swap to it.
 6750        Raises a `FocalContextCollisionError` if the name is already in
 6751        use.
 6752        """
 6753        contextMap = self.getSituation().state['contexts']
 6754        if name in contextMap:
 6755            raise FocalContextCollisionError(
 6756                f"Cannot add focal context {name!r}: a focal context"
 6757                f" with that name already exists."
 6758            )
 6759        contextMap[name] = base.emptyFocalContext()
 6760
 6761    def setActiveContext(self, which: base.FocalContextName) -> None:
 6762        """
 6763        Sets the active context to the named focal context, creating it
 6764        if it did not already exist (makes changes to the current
 6765        situation only). Does not add an exploration step (use
 6766        `advanceSituation` with a 'swap' action for that).
 6767        """
 6768        state = self.getSituation().state
 6769        contextMap = state['contexts']
 6770        if which not in contextMap:
 6771            self.addFocalContext(which)
 6772        state['activeContext'] = which
 6773
 6774    def createDomain(
 6775        self,
 6776        name: base.Domain,
 6777        focalization: base.DomainFocalization = 'singular',
 6778        makeActive: bool = False,
 6779        inCommon: Union[bool, Literal["both"]] = "both"
 6780    ) -> None:
 6781        """
 6782        Creates a new domain with the given focalization type, in either
 6783        the common context (`inCommon` = `True`) the active context
 6784        (`inCommon` = `False`) or both (the default; `inCommon` = 'both').
 6785        The domain's focalization will be set to the given
 6786        `focalization` value (default 'singular') and it will have no
 6787        active decisions. Raises a `DomainCollisionError` if a domain
 6788        with the specified name already exists.
 6789
 6790        Creates the domain in the current situation.
 6791
 6792        If `makeActive` is set to `True` (default is `False`) then the
 6793        domain will be made active in whichever context(s) it's created
 6794        in.
 6795        """
 6796        now = self.getSituation()
 6797        state = now.state
 6798        modify = []
 6799        if inCommon in (True, "both"):
 6800            modify.append(('common', state['common']))
 6801        if inCommon in (False, "both"):
 6802            acName = state['activeContext']
 6803            modify.append(
 6804                ('current ({repr(acName)})', state['contexts'][acName])
 6805            )
 6806
 6807        for (fcType, fc) in modify:
 6808            if name in fc['focalization']:
 6809                raise DomainCollisionError(
 6810                    f"Cannot create domain {repr(name)} because a"
 6811                    f" domain with that name already exists in the"
 6812                    f" {fcType} focal context."
 6813                )
 6814            fc['focalization'][name] = focalization
 6815            if makeActive:
 6816                fc['activeDomains'].add(name)
 6817            if focalization == "spreading":
 6818                fc['activeDecisions'][name] = set()
 6819            elif focalization == "plural":
 6820                fc['activeDecisions'][name] = {}
 6821            else:
 6822                fc['activeDecisions'][name] = None
 6823
 6824    def activateDomain(
 6825        self,
 6826        domain: base.Domain,
 6827        activate: bool = True,
 6828        inContext: base.ContextSpecifier = "active"
 6829    ) -> None:
 6830        """
 6831        Sets the given domain as active (or inactive if 'activate' is
 6832        given as `False`) in the specified context (default "active").
 6833
 6834        Modifies the current situation.
 6835        """
 6836        fc: base.FocalContext
 6837        if inContext == "active":
 6838            fc = self.getActiveContext()
 6839        elif inContext == "common":
 6840            fc = self.getCommonContext()
 6841
 6842        if activate:
 6843            fc['activeDomains'].add(domain)
 6844        else:
 6845            try:
 6846                fc['activeDomains'].remove(domain)
 6847            except KeyError:
 6848                pass
 6849
 6850    def createTriggerGroup(
 6851        self,
 6852        name: base.DecisionName
 6853    ) -> base.DecisionID:
 6854        """
 6855        Creates a new trigger group with the given name, returning the
 6856        decision ID for that trigger group. If this is the first trigger
 6857        group being created, also creates the `TRIGGERS_DOMAIN` domain
 6858        as a spreading-focalized domain that's active in the common
 6859        context (but does NOT set the created trigger group as an active
 6860        decision in that domain).
 6861
 6862        You can use 'goto' effects to activate trigger domains via
 6863        consequences, and 'retreat' effects to deactivate them.
 6864
 6865        Creating a second trigger group with the same name as another
 6866        results in a `ValueError`.
 6867
 6868        TODO: Retreat effects
 6869        """
 6870        ctx = self.getCommonContext()
 6871        if TRIGGERS_DOMAIN not in ctx['focalization']:
 6872            self.createDomain(
 6873                TRIGGERS_DOMAIN,
 6874                focalization='spreading',
 6875                makeActive=True,
 6876                inCommon=True
 6877            )
 6878
 6879        graph = self.getSituation().graph
 6880        if graph.getDecision(
 6881            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 6882        ) is not None:
 6883            raise ValueError(
 6884                f"Cannot create trigger group {name!r}: a trigger group"
 6885                f" with that name already exists."
 6886            )
 6887
 6888        return self.getSituation().graph.triggerGroupID(name)
 6889
 6890    def toggleTriggerGroup(
 6891        self,
 6892        name: base.DecisionName,
 6893        setActive: Union[bool, None] = None
 6894    ):
 6895        """
 6896        Toggles whether the specified trigger group (a decision in the
 6897        `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as
 6898        the `setActive` argument (instead of the default `None`) to set
 6899        the state directly instead of toggling it.
 6900
 6901        Note that trigger groups are decisions in a spreading-focalized
 6902        domain, so they can be activated or deactivated by the 'goto'
 6903        and 'retreat' effects as well.
 6904
 6905        This does not affect whether the `TRIGGERS_DOMAIN` itself is
 6906        active (normally it would always be active).
 6907
 6908        Raises a `MissingDecisionError` if the specified trigger group
 6909        does not exist yet, including when the entire `TRIGGERS_DOMAIN`
 6910        does not exist. Raises a `KeyError` if the target group exists
 6911        but the `TRIGGERS_DOMAIN` has not been set up properly.
 6912        """
 6913        ctx = self.getCommonContext()
 6914        tID = self.getSituation().graph.resolveDecision(
 6915            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 6916        )
 6917        activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN]
 6918        assert isinstance(activeGroups, set)
 6919        if tID in activeGroups:
 6920            if setActive is not True:
 6921                activeGroups.remove(tID)
 6922        else:
 6923            if setActive is not False:
 6924                activeGroups.add(tID)
 6925
 6926    def getActiveDecisions(
 6927        self,
 6928        step: Optional[int] = None,
 6929        inCommon: Union[bool, Literal["both"]] = "both"
 6930    ) -> Set[base.DecisionID]:
 6931        """
 6932        Returns the set of active decisions at the given step index, or
 6933        at the current step if no step is specified. Raises an
 6934        `IndexError` if the step index is out of bounds (see `__len__`).
 6935        May return an empty set if no decisions are active.
 6936
 6937        If `inCommon` is set to "both" (the default) then decisions
 6938        active in either the common or active context are returned. Set
 6939        it to `True` or `False` to return only decisions active in the
 6940        common (when `True`) or  active (when `False`) context.
 6941        """
 6942        if step is None:
 6943            step = -1
 6944        state = self.getSituation(step).state
 6945        if inCommon == "both":
 6946            return base.combinedDecisionSet(state)
 6947        elif inCommon is True:
 6948            return base.activeDecisionSet(state['common'])
 6949        elif inCommon is False:
 6950            return base.activeDecisionSet(
 6951                state['contexts'][state['activeContext']]
 6952            )
 6953        else:
 6954            raise ValueError(
 6955                f"Invalid inCommon value {repr(inCommon)} (must be"
 6956                f" 'both', True, or False)."
 6957            )
 6958
 6959    def setActiveDecisionsAtStep(
 6960        self,
 6961        step: int,
 6962        domain: base.Domain,
 6963        activate: Union[
 6964            base.DecisionID,
 6965            Dict[base.FocalPointName, Optional[base.DecisionID]],
 6966            Set[base.DecisionID]
 6967        ],
 6968        inCommon: bool = False
 6969    ) -> None:
 6970        """
 6971        Changes the activation status of decisions in the active
 6972        `FocalContext` at the specified step, for the specified domain
 6973        (see `currentActiveContext`). Does this without adding an
 6974        exploration step, which is unusual: normally you should use
 6975        another method like `warp` to update active decisions.
 6976
 6977        Note that this does not change which domains are active, and
 6978        setting active decisions in inactive domains does not make those
 6979        decisions active overall.
 6980
 6981        Which decisions to activate or deactivate are specified as
 6982        either a single `DecisionID`, a list of them, or a set of them,
 6983        depending on the `DomainFocalization` setting in the selected
 6984        `FocalContext` for the specified domain. A `TypeError` will be
 6985        raised if the wrong kind of decision information is provided. If
 6986        the focalization context does not have any focalization value for
 6987        the domain in question, it will be set based on the kind of
 6988        active decision information specified.
 6989
 6990        A `MissingDecisionError` will be raised if a decision is
 6991        included which is not part of the current `DecisionGraph`.
 6992        The provided information will overwrite the previous active
 6993        decision information.
 6994
 6995        If `inCommon` is set to `True`, then decisions are activated or
 6996        deactivated in the common context, instead of in the active
 6997        context.
 6998
 6999        Example:
 7000
 7001        >>> e = DiscreteExploration()
 7002        >>> e.getActiveDecisions()
 7003        set()
 7004        >>> graph = e.getSituation().graph
 7005        >>> graph.addDecision('A')
 7006        0
 7007        >>> graph.addDecision('B')
 7008        1
 7009        >>> graph.addDecision('C')
 7010        2
 7011        >>> e.setActiveDecisionsAtStep(0, 'main', 0)
 7012        >>> e.getActiveDecisions()
 7013        {0}
 7014        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 7015        >>> e.getActiveDecisions()
 7016        {1}
 7017        >>> graph = e.getSituation().graph
 7018        >>> graph.addDecision('One', domain='numbers')
 7019        3
 7020        >>> graph.addDecision('Two', domain='numbers')
 7021        4
 7022        >>> graph.addDecision('Three', domain='numbers')
 7023        5
 7024        >>> graph.addDecision('Bear', domain='animals')
 7025        6
 7026        >>> graph.addDecision('Spider', domain='animals')
 7027        7
 7028        >>> graph.addDecision('Eel', domain='animals')
 7029        8
 7030        >>> ac = e.getActiveContext()
 7031        >>> ac['focalization']['numbers'] = 'plural'
 7032        >>> ac['focalization']['animals'] = 'spreading'
 7033        >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
 7034        >>> ac['activeDecisions']['animals'] = set()
 7035        >>> cc = e.getCommonContext()
 7036        >>> cc['focalization']['numbers'] = 'plural'
 7037        >>> cc['focalization']['animals'] = 'spreading'
 7038        >>> cc['activeDecisions']['numbers'] = {'z': None}
 7039        >>> cc['activeDecisions']['animals'] = set()
 7040        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
 7041        >>> e.getActiveDecisions()
 7042        {1}
 7043        >>> e.activateDomain('numbers')
 7044        >>> e.getActiveDecisions()
 7045        {1, 3}
 7046        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
 7047        >>> e.getActiveDecisions()
 7048        {1, 4}
 7049        >>> # Wrong domain for the decision ID:
 7050        >>> e.setActiveDecisionsAtStep(0, 'main', 3)
 7051        Traceback (most recent call last):
 7052        ...
 7053        ValueError...
 7054        >>> # Wrong domain for one of the decision IDs:
 7055        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
 7056        Traceback (most recent call last):
 7057        ...
 7058        ValueError...
 7059        >>> # Wrong kind of decision information provided.
 7060        >>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
 7061        Traceback (most recent call last):
 7062        ...
 7063        TypeError...
 7064        >>> e.getActiveDecisions()
 7065        {1, 4}
 7066        >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
 7067        >>> e.getActiveDecisions()
 7068        {1, 4}
 7069        >>> e.activateDomain('animals')
 7070        >>> e.getActiveDecisions()
 7071        {1, 4, 6, 7}
 7072        >>> e.setActiveDecisionsAtStep(0, 'animals', {8})
 7073        >>> e.getActiveDecisions()
 7074        {8, 1, 4}
 7075        >>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
 7076        Traceback (most recent call last):
 7077        ...
 7078        IndexError...
 7079        >>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
 7080        Traceback (most recent call last):
 7081        ...
 7082        ValueError...
 7083
 7084        Example of active/common contexts:
 7085
 7086        >>> e = DiscreteExploration()
 7087        >>> graph = e.getSituation().graph
 7088        >>> graph.addDecision('A')
 7089        0
 7090        >>> graph.addDecision('B')
 7091        1
 7092        >>> e.activateDomain('main', inContext="common")
 7093        >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
 7094        >>> e.getActiveDecisions()
 7095        {0}
 7096        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 7097        >>> e.getActiveDecisions()
 7098        {0}
 7099        >>> # (Still active since it's active in the common context)
 7100        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 7101        >>> e.getActiveDecisions()
 7102        {0, 1}
 7103        >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
 7104        >>> e.getActiveDecisions()
 7105        {1}
 7106        >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
 7107        >>> e.getActiveDecisions()
 7108        {1}
 7109        >>> # (Still active since it's active in the active context)
 7110        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 7111        >>> e.getActiveDecisions()
 7112        set()
 7113        """
 7114        now = self.getSituation(step)
 7115        graph = now.graph
 7116        if inCommon:
 7117            context = self.getCommonContext(step)
 7118        else:
 7119            context = self.getActiveContext(step)
 7120
 7121        defaultFocalization: base.DomainFocalization = 'singular'
 7122        if isinstance(activate, base.DecisionID):
 7123            defaultFocalization = 'singular'
 7124        elif isinstance(activate, dict):
 7125            defaultFocalization = 'plural'
 7126        elif isinstance(activate, set):
 7127            defaultFocalization = 'spreading'
 7128        elif domain not in context['focalization']:
 7129            raise TypeError(
 7130                f"Domain {domain!r} has no focalization in the"
 7131                f" {'common' if inCommon else 'active'} context,"
 7132                f" and the specified position doesn't imply one."
 7133            )
 7134
 7135        focalization = base.getDomainFocalization(
 7136            context,
 7137            domain,
 7138            defaultFocalization
 7139        )
 7140
 7141        # Check domain & existence of decision(s) in question
 7142        if activate is None:
 7143            pass
 7144        elif isinstance(activate, base.DecisionID):
 7145            if activate not in graph:
 7146                raise MissingDecisionError(
 7147                    f"There is no decision {activate} at step {step}."
 7148                )
 7149            if graph.domainFor(activate) != domain:
 7150                raise ValueError(
 7151                    f"Can't set active decisions in domain {domain!r}"
 7152                    f" to decision {graph.identityOf(activate)} because"
 7153                    f" that decision is in actually in domain"
 7154                    f" {graph.domainFor(activate)!r}."
 7155                )
 7156        elif isinstance(activate, dict):
 7157            for fpName, pos in activate.items():
 7158                if pos is None:
 7159                    continue
 7160                if pos not in graph:
 7161                    raise MissingDecisionError(
 7162                        f"There is no decision {pos} at step {step}."
 7163                    )
 7164                if graph.domainFor(pos) != domain:
 7165                    raise ValueError(
 7166                        f"Can't set active decision for focal point"
 7167                        f" {fpName!r} in domain {domain!r}"
 7168                        f" to decision {graph.identityOf(pos)} because"
 7169                        f" that decision is in actually in domain"
 7170                        f" {graph.domainFor(pos)!r}."
 7171                    )
 7172        elif isinstance(activate, set):
 7173            for pos in activate:
 7174                if pos not in graph:
 7175                    raise MissingDecisionError(
 7176                        f"There is no decision {pos} at step {step}."
 7177                    )
 7178                if graph.domainFor(pos) != domain:
 7179                    raise ValueError(
 7180                        f"Can't set {graph.identityOf(pos)} as an"
 7181                        f" active decision in domain {domain!r} to"
 7182                        f" decision because that decision is in"
 7183                        f" actually in domain {graph.domainFor(pos)!r}."
 7184                    )
 7185        else:
 7186            raise TypeError(
 7187                f"Domain {domain!r} has no focalization in the"
 7188                f" {'common' if inCommon else 'active'} context,"
 7189                f" and the specified position doesn't imply one:"
 7190                f"\n{activate!r}"
 7191            )
 7192
 7193        if focalization == 'singular':
 7194            if activate is None or isinstance(activate, base.DecisionID):
 7195                if activate is not None:
 7196                    targetDomain = graph.domainFor(activate)
 7197                    if activate not in graph:
 7198                        raise MissingDecisionError(
 7199                            f"There is no decision {activate} in the"
 7200                            f" graph at step {step}."
 7201                        )
 7202                    elif targetDomain != domain:
 7203                        raise ValueError(
 7204                            f"At step {step}, decision {activate} cannot"
 7205                            f" be the active decision for domain"
 7206                            f" {repr(domain)} because it is in a"
 7207                            f" different domain ({repr(targetDomain)})."
 7208                        )
 7209                context['activeDecisions'][domain] = activate
 7210            else:
 7211                raise TypeError(
 7212                    f"{'Common' if inCommon else 'Active'} focal"
 7213                    f" context at step {step} has {repr(focalization)}"
 7214                    f" focalization for domain {repr(domain)}, so the"
 7215                    f" active decision must be a single decision or"
 7216                    f" None.\n(You provided: {repr(activate)})"
 7217                )
 7218        elif focalization == 'plural':
 7219            if (
 7220                isinstance(activate, dict)
 7221            and all(
 7222                    isinstance(k, base.FocalPointName)
 7223                    for k in activate.keys()
 7224                )
 7225            and all(
 7226                    v is None or isinstance(v, base.DecisionID)
 7227                    for v in activate.values()
 7228                )
 7229            ):
 7230                for v in activate.values():
 7231                    if v is not None:
 7232                        targetDomain = graph.domainFor(v)
 7233                        if v not in graph:
 7234                            raise MissingDecisionError(
 7235                                f"There is no decision {v} in the graph"
 7236                                f" at step {step}."
 7237                            )
 7238                        elif targetDomain != domain:
 7239                            raise ValueError(
 7240                                f"At step {step}, decision {activate}"
 7241                                f" cannot be an active decision for"
 7242                                f" domain {repr(domain)} because it is"
 7243                                f" in a different domain"
 7244                                f" ({repr(targetDomain)})."
 7245                            )
 7246                context['activeDecisions'][domain] = activate
 7247            else:
 7248                raise TypeError(
 7249                    f"{'Common' if inCommon else 'Active'} focal"
 7250                    f" context at step {step} has {repr(focalization)}"
 7251                    f" focalization for domain {repr(domain)}, so the"
 7252                    f" active decision must be a dictionary mapping"
 7253                    f" focal point names to decision IDs (or Nones)."
 7254                    f"\n(You provided: {repr(activate)})"
 7255                )
 7256        elif focalization == 'spreading':
 7257            if (
 7258                isinstance(activate, set)
 7259            and all(isinstance(x, base.DecisionID) for x in activate)
 7260            ):
 7261                for x in activate:
 7262                    targetDomain = graph.domainFor(x)
 7263                    if x not in graph:
 7264                        raise MissingDecisionError(
 7265                            f"There is no decision {x} in the graph"
 7266                            f" at step {step}."
 7267                        )
 7268                    elif targetDomain != domain:
 7269                        raise ValueError(
 7270                            f"At step {step}, decision {activate}"
 7271                            f" cannot be an active decision for"
 7272                            f" domain {repr(domain)} because it is"
 7273                            f" in a different domain"
 7274                            f" ({repr(targetDomain)})."
 7275                        )
 7276                context['activeDecisions'][domain] = activate
 7277            else:
 7278                raise TypeError(
 7279                    f"{'Common' if inCommon else 'Active'} focal"
 7280                    f" context at step {step} has {repr(focalization)}"
 7281                    f" focalization for domain {repr(domain)}, so the"
 7282                    f" active decision must be a set of decision IDs"
 7283                    f"\n(You provided: {repr(activate)})"
 7284                )
 7285        else:
 7286            raise RuntimeError(
 7287                f"Invalid focalization value {repr(focalization)} for"
 7288                f" domain {repr(domain)} at step {step}."
 7289            )
 7290
 7291    def movementAtStep(self, step: int = -1) -> Tuple[
 7292        Union[base.DecisionID, Set[base.DecisionID], None],
 7293        Optional[base.Transition],
 7294        Union[base.DecisionID, Set[base.DecisionID], None]
 7295    ]:
 7296        """
 7297        Given a step number, returns information about the starting
 7298        decision, transition taken, and destination decision for that
 7299        step. Not all steps have all of those, so some items may be
 7300        `None`.
 7301
 7302        For steps where there is no action, where a decision is still
 7303        pending, or where the action type is 'focus', 'swap', 'focalize',
 7304        or 'revertTo', the result will be `(None, None, None)`, unless a
 7305        primary decision is available in which case the first item in the
 7306        tuple will be that decision. For 'start' actions, the starting
 7307        position and transition will be `None` (again unless the step had
 7308        a primary decision) but the destination will be the ID of the
 7309        node started at. For 'revertTo' actions, the destination will be
 7310        the primary decision of the state reverted to, if available.
 7311
 7312        Also, if the action taken has multiple potential or actual start
 7313        or end points, these may be sets of decision IDs instead of
 7314        single IDs.
 7315
 7316        Note that the primary decision of the starting state is usually
 7317        used as the from-decision, but in some cases an action dictates
 7318        taking a transition from a different decision, and this function
 7319        will return that decision as the from-decision.
 7320
 7321        TODO: Examples!
 7322
 7323        TODO: Account for bounce/follow/goto effects!!!
 7324        """
 7325        now = self.getSituation(step)
 7326        action = now.action
 7327        graph = now.graph
 7328        primary = now.state['primaryDecision']
 7329
 7330        if action is None:
 7331            return (primary, None, None)
 7332
 7333        aType = action[0]
 7334        fromID: Optional[base.DecisionID]
 7335        destID: Optional[base.DecisionID]
 7336        transition: base.Transition
 7337        outcomes: List[bool]
 7338
 7339        if aType in ('noAction', 'focus', 'swap', 'focalize'):
 7340            return (primary, None, None)
 7341        elif aType == 'start':
 7342            assert len(action) == 7
 7343            where = cast(
 7344                Union[
 7345                    base.DecisionID,
 7346                    Dict[base.FocalPointName, base.DecisionID],
 7347                    Set[base.DecisionID]
 7348                ],
 7349                action[1]
 7350            )
 7351            if isinstance(where, dict):
 7352                where = set(where.values())
 7353            return (primary, None, where)
 7354        elif aType in ('take', 'explore'):
 7355            if (
 7356                (len(action) == 4 or len(action) == 7)
 7357            and isinstance(action[2], base.DecisionID)
 7358            ):
 7359                fromID = action[2]
 7360                assert isinstance(action[3], tuple)
 7361                transition, outcomes = action[3]
 7362                if (
 7363                    action[0] == "explore"
 7364                and isinstance(action[4], base.DecisionID)
 7365                ):
 7366                    destID = action[4]
 7367                else:
 7368                    destID = graph.getDestination(fromID, transition)
 7369                return (fromID, transition, destID)
 7370            elif (
 7371                (len(action) == 3 or len(action) == 6)
 7372            and isinstance(action[1], tuple)
 7373            and isinstance(action[2], base.Transition)
 7374            and len(action[1]) == 3
 7375            and action[1][0] in get_args(base.ContextSpecifier)
 7376            and isinstance(action[1][1], base.Domain)
 7377            and isinstance(action[1][2], base.FocalPointName)
 7378            ):
 7379                fromID = base.resolvePosition(now, action[1])
 7380                if fromID is None:
 7381                    raise InvalidActionError(
 7382                        f"{aType!r} action at step {step} has position"
 7383                        f" {action[1]!r} which cannot be resolved to a"
 7384                        f" decision."
 7385                    )
 7386                transition, outcomes = action[2]
 7387                if (
 7388                    action[0] == "explore"
 7389                and isinstance(action[3], base.DecisionID)
 7390                ):
 7391                    destID = action[3]
 7392                else:
 7393                    destID = graph.getDestination(fromID, transition)
 7394                return (fromID, transition, destID)
 7395            else:
 7396                raise InvalidActionError(
 7397                    f"Malformed {aType!r} action:\n{repr(action)}"
 7398                )
 7399        elif aType == 'warp':
 7400            if len(action) != 3:
 7401                raise InvalidActionError(
 7402                    f"Malformed 'warp' action:\n{repr(action)}"
 7403                )
 7404            dest = action[2]
 7405            assert isinstance(dest, base.DecisionID)
 7406            if action[1] in get_args(base.ContextSpecifier):
 7407                # Unspecified starting point; find active decisions in
 7408                # same domain if primary is None
 7409                if primary is not None:
 7410                    return (primary, None, dest)
 7411                else:
 7412                    toDomain = now.graph.domainFor(dest)
 7413                    # TODO: Could check destination focalization here...
 7414                    active = self.getActiveDecisions(step)
 7415                    sameDomain = set(
 7416                        dID
 7417                        for dID in active
 7418                        if now.graph.domainFor(dID) == toDomain
 7419                    )
 7420                    if len(sameDomain) == 1:
 7421                        return (
 7422                            list(sameDomain)[0],
 7423                            None,
 7424                            dest
 7425                        )
 7426                    else:
 7427                        return (
 7428                            sameDomain,
 7429                            None,
 7430                            dest
 7431                        )
 7432            else:
 7433                if (
 7434                    not isinstance(action[1], tuple)
 7435                or not len(action[1]) == 3
 7436                or not action[1][0] in get_args(base.ContextSpecifier)
 7437                or not isinstance(action[1][1], base.Domain)
 7438                or not isinstance(action[1][2], base.FocalPointName)
 7439                ):
 7440                    raise InvalidActionError(
 7441                        f"Malformed 'warp' action:\n{repr(action)}"
 7442                    )
 7443                return (
 7444                    base.resolvePosition(now, action[1]),
 7445                    None,
 7446                    dest
 7447                )
 7448        elif aType == 'revertTo':
 7449            assert len(action) == 3  # type, save slot, & aspects
 7450            if primary is not None:
 7451                cameFrom = primary
 7452            nextSituation = self.getSituation(step + 1)
 7453            wentTo = nextSituation.state['primaryDecision']
 7454            return (primary, None, wentTo)
 7455        else:
 7456            raise InvalidActionError(
 7457                f"Action taken had invalid action type {repr(aType)}:"
 7458                f"\n{repr(action)}"
 7459            )
 7460
 7461    def latestStepWithDecision(
 7462        self,
 7463        dID: base.DecisionID,
 7464        startFrom: int = -1
 7465    ) -> int:
 7466        """
 7467        Scans backwards through exploration steps until it finds a graph
 7468        that contains a decision with the specified ID, and returns the
 7469        step number of that step. Instead of starting from the last step,
 7470        you can tell it to start from a different step (either positive
 7471        or negative index) via `startFrom`. Raises a
 7472        `MissingDecisionError` if there is no such step.
 7473        """
 7474        if startFrom < 0:
 7475            startFrom = len(self) + startFrom
 7476        for step in range(startFrom, -1, -1):
 7477            graph = self.getSituation(step).graph
 7478            try:
 7479                return step
 7480            except MissingDecisionError:
 7481                continue
 7482        raise MissingDecisionError(
 7483            f"Decision {dID!r} does not exist at any step of the"
 7484            f" exploration."
 7485        )
 7486
 7487    def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
 7488        """
 7489        Looks up decision info for the given decision in the latest step
 7490        in which that decision exists (which will usually be the final
 7491        exploration step, unless the decision was merged or otherwise
 7492        removed along the way). This will raise a `MissingDecisionError`
 7493        only if there is no step at which the specified decision exists.
 7494        """
 7495        for step in range(len(self) - 1, -1, -1):
 7496            graph = self.getSituation(step).graph
 7497            try:
 7498                return graph.decisionInfo(dID)
 7499            except MissingDecisionError:
 7500                continue
 7501        raise MissingDecisionError(
 7502            f"Decision {dID!r} does not exist at any step of the"
 7503            f" exploration."
 7504        )
 7505
 7506    def latestTransitionProperties(
 7507        self,
 7508        dID: base.DecisionID,
 7509        transition: base.Transition
 7510    ) -> TransitionProperties:
 7511        """
 7512        Looks up transition properties for the transition with the given
 7513        name outgoing from the decision with the given ID, in the latest
 7514        step in which a transiiton with that name from that decision
 7515        exists (which will usually be the final exploration step, unless
 7516        transitions get removed/renamed along the way). Note that because
 7517        a transition can be deleted and later added back (unlike
 7518        decisions where an ID will not be re-used), it's possible there
 7519        are two or more different transitions that meet the
 7520        specifications at different points in time, and this will always
 7521        return the properties of the last of them. This will raise a
 7522        `MissingDecisionError` if there is no step at which the specified
 7523        decision exists, and a `MissingTransitionError` if the target
 7524        decision exists at some step but never has a transition with the
 7525        specified name.
 7526        """
 7527        sawDecision: Optional[int] = None
 7528        for step in range(len(self) - 1, -1, -1):
 7529            graph = self.getSituation(step).graph
 7530            try:
 7531                return graph.getTransitionProperties(dID, transition)
 7532            except (MissingDecisionError, MissingTransitionError) as e:
 7533                if (
 7534                    sawDecision is None
 7535                and isinstance(e, MissingTransitionError)
 7536                ):
 7537                    sawDecision = step
 7538                continue
 7539        if sawDecision is None:
 7540            raise MissingDecisionError(
 7541                f"Decision {dID!r} does not exist at any step of the"
 7542                f" exploration."
 7543            )
 7544        else:
 7545            raise MissingTransitionError(
 7546                f"Decision {dID!r} does exist (last seen at step"
 7547                f" {sawDecision}) but it never has an outgoing"
 7548                f" transition named {transition!r}."
 7549            )
 7550
 7551    def tagStep(
 7552        self,
 7553        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 7554        tagValue: Union[
 7555            base.TagValue,
 7556            type[base.NoTagValue]
 7557        ] = base.NoTagValue,
 7558        step: int = -1
 7559    ) -> None:
 7560        """
 7561        Adds a tag (or multiple tags) to the current step, or to a
 7562        specific step if `n` is given as an integer rather than the
 7563        default `None`. A tag value should be supplied when a tag is
 7564        given (unless you want to use the default of `1`), but it's a
 7565        `ValueError` to supply a tag value when a dictionary of tags to
 7566        update is provided.
 7567        """
 7568        if isinstance(tagOrTags, base.Tag):
 7569            if tagValue is base.NoTagValue:
 7570                tagValue = 1
 7571
 7572            # Not sure why this is necessary...
 7573            tagValue = cast(base.TagValue, tagValue)
 7574
 7575            self.getSituation(step).tags.update({tagOrTags: tagValue})
 7576        else:
 7577            self.getSituation(step).tags.update(tagOrTags)
 7578
 7579    def annotateStep(
 7580        self,
 7581        annotationOrAnnotations: Union[
 7582            base.Annotation,
 7583            Sequence[base.Annotation]
 7584        ],
 7585        step: Optional[int] = None
 7586    ) -> None:
 7587        """
 7588        Adds an annotation to the current exploration step, or to a
 7589        specific step if `n` is given as an integer rather than the
 7590        default `None`.
 7591        """
 7592        if step is None:
 7593            step = -1
 7594        if isinstance(annotationOrAnnotations, base.Annotation):
 7595            self.getSituation(step).annotations.append(
 7596                annotationOrAnnotations
 7597            )
 7598        else:
 7599            self.getSituation(step).annotations.extend(
 7600                annotationOrAnnotations
 7601            )
 7602
 7603    def hasCapability(
 7604        self,
 7605        capability: base.Capability,
 7606        step: Optional[int] = None,
 7607        inCommon: Union[bool, Literal['both']] = "both"
 7608    ) -> bool:
 7609        """
 7610        Returns True if the player currently had the specified
 7611        capability, at the specified exploration step, and False
 7612        otherwise. Checks the current state if no step is given. Does
 7613        NOT return true if the game state means that the player has an
 7614        equivalent for that capability (see
 7615        `hasCapabilityOrEquivalent`).
 7616
 7617        Normally, `inCommon` is set to 'both' by default and so if
 7618        either the common `FocalContext` or the active one has the
 7619        capability, this will return `True`. `inCommon` may instead be
 7620        set to `True` or `False` to ask about just the common (or
 7621        active) focal context.
 7622        """
 7623        state = self.getSituation().state
 7624        commonCapabilities = state['common']['capabilities']\
 7625            ['capabilities']  # noqa
 7626        activeCapabilities = state['contexts'][state['activeContext']]\
 7627            ['capabilities']['capabilities']  # noqa
 7628
 7629        if inCommon == 'both':
 7630            return (
 7631                capability in commonCapabilities
 7632             or capability in activeCapabilities
 7633            )
 7634        elif inCommon is True:
 7635            return capability in commonCapabilities
 7636        elif inCommon is False:
 7637            return capability in activeCapabilities
 7638        else:
 7639            raise ValueError(
 7640                f"Invalid inCommon value (must be False, True, or"
 7641                f" 'both'; got {repr(inCommon)})."
 7642            )
 7643
 7644    def hasCapabilityOrEquivalent(
 7645        self,
 7646        capability: base.Capability,
 7647        step: Optional[int] = None,
 7648        location: Optional[Set[base.DecisionID]] = None
 7649    ) -> bool:
 7650        """
 7651        Works like `hasCapability`, but also returns `True` if the
 7652        player counts as having the specified capability via an equivalence
 7653        that's part of the current graph. As with `hasCapability`, the
 7654        optional `step` argument is used to specify which step to check,
 7655        with the current step being used as the default.
 7656
 7657        The `location` set can specify where to start looking for
 7658        mechanisms; if left unspecified active decisions for that step
 7659        will be used.
 7660        """
 7661        if step is None:
 7662            step = -1
 7663        if location is None:
 7664            location = self.getActiveDecisions(step)
 7665        situation = self.getSituation(step)
 7666        return base.hasCapabilityOrEquivalent(
 7667            capability,
 7668            base.RequirementContext(
 7669                state=situation.state,
 7670                graph=situation.graph,
 7671                searchFrom=location
 7672            )
 7673        )
 7674
 7675    def gainCapabilityNow(
 7676        self,
 7677        capability: base.Capability,
 7678        inCommon: bool = False
 7679    ) -> None:
 7680        """
 7681        Modifies the current game state to add the specified `Capability`
 7682        to the player's capabilities. No changes are made to the current
 7683        graph.
 7684
 7685        If `inCommon` is set to `True` (default is `False`) then the
 7686        capability will be added to the common `FocalContext` and will
 7687        therefore persist even when a focal context switch happens.
 7688        Normally, it will be added to the currently-active focal
 7689        context.
 7690        """
 7691        state = self.getSituation().state
 7692        if inCommon:
 7693            context = state['common']
 7694        else:
 7695            context = state['contexts'][state['activeContext']]
 7696        context['capabilities']['capabilities'].add(capability)
 7697
 7698    def loseCapabilityNow(
 7699        self,
 7700        capability: base.Capability,
 7701        inCommon: Union[bool, Literal['both']] = "both"
 7702    ) -> None:
 7703        """
 7704        Modifies the current game state to remove the specified `Capability`
 7705        from the player's capabilities. Does nothing if the player
 7706        doesn't already have that capability.
 7707
 7708        By default, this removes the capability from both the common
 7709        capabilities set and the active `FocalContext`'s capabilities
 7710        set, so that afterwards the player will definitely not have that
 7711        capability. However, if you set `inCommon` to either `True` or
 7712        `False`, it will remove the capability from just the common
 7713        capabilities set (if `True`) or just the active capabilities set
 7714        (if `False`). In these cases, removing the capability from just
 7715        one capability set will not actually remove it in terms of the
 7716        `hasCapability` result if it had been present in the other set.
 7717        Set `inCommon` to "both" to use the default behavior explicitly.
 7718        """
 7719        now = self.getSituation()
 7720        if inCommon in ("both", True):
 7721            context = now.state['common']
 7722            try:
 7723                context['capabilities']['capabilities'].remove(capability)
 7724            except KeyError:
 7725                pass
 7726        elif inCommon in ("both", False):
 7727            context = now.state['contexts'][now.state['activeContext']]
 7728            try:
 7729                context['capabilities']['capabilities'].remove(capability)
 7730            except KeyError:
 7731                pass
 7732        else:
 7733            raise ValueError(
 7734                f"Invalid inCommon value (must be False, True, or"
 7735                f" 'both'; got {repr(inCommon)})."
 7736            )
 7737
 7738    def tokenCountNow(self, tokenType: base.Token) -> Optional[int]:
 7739        """
 7740        Returns the number of tokens the player currently has of a given
 7741        type. Returns `None` if the player has never acquired or lost
 7742        tokens of that type.
 7743
 7744        This method adds together tokens from the common and active
 7745        focal contexts.
 7746        """
 7747        state = self.getSituation().state
 7748        commonContext = state['common']
 7749        activeContext = state['contexts'][state['activeContext']]
 7750        base = commonContext['capabilities']['tokens'].get(tokenType)
 7751        if base is None:
 7752            return activeContext['capabilities']['tokens'].get(tokenType)
 7753        else:
 7754            return base + activeContext['capabilities']['tokens'].get(
 7755                tokenType,
 7756                0
 7757            )
 7758
 7759    def adjustTokensNow(
 7760        self,
 7761        tokenType: base.Token,
 7762        amount: int,
 7763        inCommon: bool = False
 7764    ) -> None:
 7765        """
 7766        Modifies the current game state to add the specified number of
 7767        `Token`s of the given type to the player's tokens. No changes are
 7768        made to the current graph. Reduce the number of tokens by
 7769        supplying a negative amount; note that negative token amounts
 7770        are possible.
 7771
 7772        By default, the number of tokens for the current active
 7773        `FocalContext` will be adjusted. However, if `inCommon` is set
 7774        to `True`, then the number of tokens for the common context will
 7775        be adjusted instead.
 7776        """
 7777        # TODO: Custom token caps!
 7778        state = self.getSituation().state
 7779        if inCommon:
 7780            context = state['common']
 7781        else:
 7782            context = state['contexts'][state['activeContext']]
 7783        tokens = context['capabilities']['tokens']
 7784        tokens[tokenType] = tokens.get(tokenType, 0) + amount
 7785
 7786    def setTokensNow(
 7787        self,
 7788        tokenType: base.Token,
 7789        amount: int,
 7790        inCommon: bool = False
 7791    ) -> None:
 7792        """
 7793        Modifies the current game state to set number of `Token`s of the
 7794        given type to a specific amount, regardless of the old value. No
 7795        changes are made to the current graph.
 7796
 7797        By default this sets the number of tokens for the active
 7798        `FocalContext`. But if you set `inCommon` to `True`, it will
 7799        set the number of tokens in the common context instead.
 7800        """
 7801        # TODO: Custom token caps!
 7802        state = self.getSituation().state
 7803        if inCommon:
 7804            context = state['common']
 7805        else:
 7806            context = state['contexts'][state['activeContext']]
 7807        context['capabilities']['tokens'][tokenType] = amount
 7808
 7809    def lookupMechanism(
 7810        self,
 7811        mechanism: base.MechanismName,
 7812        step: Optional[int] = None,
 7813        where: Union[
 7814            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 7815            Collection[base.AnyDecisionSpecifier],
 7816            None
 7817        ] = None
 7818    ) -> base.MechanismID:
 7819        """
 7820        Looks up a mechanism ID by name, in the graph for the specified
 7821        step. The `where` argument specifies where to start looking,
 7822        which helps disambiguate. It can be a tuple with a decision
 7823        specifier and `None` to start from a single decision, or with a
 7824        decision specifier and a transition name to start from either
 7825        end of that transition. It can also be `None` to look at global
 7826        mechanisms and then all decisions directly, although this
 7827        increases the chance of a `MechanismCollisionError`. Finally, it
 7828        can be some other non-tuple collection of decision specifiers to
 7829        start from that set.
 7830
 7831        If no step is specified, uses the current step.
 7832        """
 7833        if step is None:
 7834            step = -1
 7835        situation = self.getSituation(step)
 7836        graph = situation.graph
 7837        searchFrom: Collection[base.AnyDecisionSpecifier]
 7838        if where is None:
 7839            searchFrom = set()
 7840        elif isinstance(where, tuple):
 7841            if len(where) != 2:
 7842                raise ValueError(
 7843                    f"Mechanism lookup location was a tuple with an"
 7844                    f" invalid length (must be length-2 if it's a"
 7845                    f" tuple):\n  {repr(where)}"
 7846                )
 7847            where = cast(
 7848                Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 7849                where
 7850            )
 7851            if where[1] is None:
 7852                searchFrom = {graph.resolveDecision(where[0])}
 7853            else:
 7854                searchFrom = graph.bothEnds(where[0], where[1])
 7855        else:  # must be a collection of specifiers
 7856            searchFrom = cast(Collection[base.AnyDecisionSpecifier], where)
 7857        return graph.lookupMechanism(searchFrom, mechanism)
 7858
 7859    def mechanismState(
 7860        self,
 7861        mechanism: base.AnyMechanismSpecifier,
 7862        where: Optional[Set[base.DecisionID]] = None,
 7863        step: int = -1
 7864    ) -> Optional[base.MechanismState]:
 7865        """
 7866        Returns the current state for the specified mechanism (or the
 7867        state at the specified step if a step index is given). `where`
 7868        may be provided as a set of decision IDs to indicate where to
 7869        search for the named mechanism, or a mechanism ID may be provided
 7870        in the first place. Mechanism states are properties of a `State`
 7871        but are not associated with focal contexts.
 7872        """
 7873        situation = self.getSituation(step)
 7874        mID = situation.graph.resolveMechanism(mechanism, startFrom=where)
 7875        return situation.state['mechanisms'].get(
 7876            mID,
 7877            base.DEFAULT_MECHANISM_STATE
 7878        )
 7879
 7880    def setMechanismStateNow(
 7881        self,
 7882        mechanism: base.AnyMechanismSpecifier,
 7883        toState: base.MechanismState,
 7884        where: Optional[Set[base.DecisionID]] = None
 7885    ) -> None:
 7886        """
 7887        Sets the state of the specified mechanism to the specified
 7888        state. Mechanisms can only be in one state at once, so this
 7889        removes any previous states for that mechanism (note that via
 7890        equivalences multiple mechanism states can count as active).
 7891
 7892        The mechanism can be any kind of mechanism specifier (see
 7893        `base.AnyMechanismSpecifier`). If it's not a mechanism ID and
 7894        doesn't have its own position information, the 'where' argument
 7895        can be used to hint where to search for the mechanism.
 7896        """
 7897        now = self.getSituation()
 7898        mID = now.graph.resolveMechanism(mechanism, startFrom=where)
 7899        if mID is None:
 7900            raise MissingMechanismError(
 7901                f"Couldn't find mechanism for {repr(mechanism)}."
 7902            )
 7903        now.state['mechanisms'][mID] = toState
 7904
 7905    def skillLevel(
 7906        self,
 7907        skill: base.Skill,
 7908        step: Optional[int] = None
 7909    ) -> Optional[base.Level]:
 7910        """
 7911        Returns the skill level the player had in a given skill at a
 7912        given step, or for the current step if no step is specified.
 7913        Returns `None` if the player had never acquired or lost levels
 7914        in that skill before the specified step (skill level would count
 7915        as 0 in that case).
 7916
 7917        This method adds together levels from the common and active
 7918        focal contexts.
 7919        """
 7920        if step is None:
 7921            step = -1
 7922        state = self.getSituation(step).state
 7923        commonContext = state['common']
 7924        activeContext = state['contexts'][state['activeContext']]
 7925        base = commonContext['capabilities']['skills'].get(skill)
 7926        if base is None:
 7927            return activeContext['capabilities']['skills'].get(skill)
 7928        else:
 7929            return base + activeContext['capabilities']['skills'].get(
 7930                skill,
 7931                0
 7932            )
 7933
 7934    def adjustSkillLevelNow(
 7935        self,
 7936        skill: base.Skill,
 7937        levels: base.Level,
 7938        inCommon: bool = False
 7939    ) -> None:
 7940        """
 7941        Modifies the current game state to add the specified number of
 7942        `Level`s of the given skill. No changes are made to the current
 7943        graph. Reduce the skill level by supplying negative levels; note
 7944        that negative skill levels are possible.
 7945
 7946        By default, the skill level for the current active
 7947        `FocalContext` will be adjusted. However, if `inCommon` is set
 7948        to `True`, then the skill level for the common context will be
 7949        adjusted instead.
 7950        """
 7951        # TODO: Custom level caps?
 7952        state = self.getSituation().state
 7953        if inCommon:
 7954            context = state['common']
 7955        else:
 7956            context = state['contexts'][state['activeContext']]
 7957        skills = context['capabilities']['skills']
 7958        skills[skill] = skills.get(skill, 0) + levels
 7959
 7960    def setSkillLevelNow(
 7961        self,
 7962        skill: base.Skill,
 7963        level: base.Level,
 7964        inCommon: bool = False
 7965    ) -> None:
 7966        """
 7967        Modifies the current game state to set `Skill` `Level` for the
 7968        given skill, regardless of the old value. No changes are made to
 7969        the current graph.
 7970
 7971        By default this sets the skill level for the active
 7972        `FocalContext`. But if you set `inCommon` to `True`, it will set
 7973        the skill level in the common context instead.
 7974        """
 7975        # TODO: Custom level caps?
 7976        state = self.getSituation().state
 7977        if inCommon:
 7978            context = state['common']
 7979        else:
 7980            context = state['contexts'][state['activeContext']]
 7981        skills = context['capabilities']['skills']
 7982        skills[skill] = level
 7983
 7984    def updateRequirementNow(
 7985        self,
 7986        decision: base.AnyDecisionSpecifier,
 7987        transition: base.Transition,
 7988        requirement: Optional[base.Requirement]
 7989    ) -> None:
 7990        """
 7991        Updates the requirement for a specific transition in a specific
 7992        decision. Use `None` to remove the requirement for that edge.
 7993        """
 7994        if requirement is None:
 7995            requirement = base.ReqNothing()
 7996        self.getSituation().graph.setTransitionRequirement(
 7997            decision,
 7998            transition,
 7999            requirement
 8000        )
 8001
 8002    def isTraversable(
 8003        self,
 8004        decision: base.AnyDecisionSpecifier,
 8005        transition: base.Transition,
 8006        step: int = -1
 8007    ) -> bool:
 8008        """
 8009        Returns True if the specified transition from the specified
 8010        decision had its requirement satisfied by the game state at the
 8011        specified step (or at the current step if no step is specified).
 8012        Raises an `IndexError` if the specified step doesn't exist, and
 8013        a `KeyError` if the decision or transition specified does not
 8014        exist in the `DecisionGraph` at that step.
 8015        """
 8016        situation = self.getSituation(step)
 8017        req = situation.graph.getTransitionRequirement(decision, transition)
 8018        ctx = base.contextForTransition(situation, decision, transition)
 8019        fromID = situation.graph.resolveDecision(decision)
 8020        return (
 8021            req.satisfied(ctx)
 8022        and (fromID, transition) not in situation.state['deactivated']
 8023        )
 8024
 8025    def applyTransitionEffect(
 8026        self,
 8027        whichEffect: base.EffectSpecifier,
 8028        moveWhich: Optional[base.FocalPointName] = None
 8029    ) -> Optional[base.DecisionID]:
 8030        """
 8031        Applies an effect attached to a transition, taking charges and
 8032        delay into account based on the current `Situation`.
 8033        Modifies the effect's trigger count (but may not actually
 8034        trigger the effect if the charges and/or delay values indicate
 8035        not to; see `base.doTriggerEffect`).
 8036
 8037        If a specific focal point in a plural-focalized domain is
 8038        triggering the effect, the focal point name should be specified
 8039        via `moveWhich` so that goto `Effect`s can know which focal
 8040        point to move when it's not explicitly specified in the effect.
 8041        TODO: Test this!
 8042
 8043        Returns None most of the time, but if a 'goto', 'bounce', or
 8044        'follow' effect was applied, it returns the decision ID for that
 8045        effect's destination, which would override a transition's normal
 8046        destination. If it returns a destination ID, then the exploration
 8047        state will already have been updated to set the position there,
 8048        and further position updates are not needed.
 8049
 8050        Note that transition effects which update active decisions will
 8051        also update the exploration status of those decisions to
 8052        'exploring' if they had been in an unvisited status (see
 8053        `updatePosition` and `hasBeenVisited`).
 8054
 8055        Note: callers should immediately update situation-based variables
 8056        that might have been changes by a 'revert' effect.
 8057        """
 8058        now = self.getSituation()
 8059        effect, triggerCount = base.doTriggerEffect(now, whichEffect)
 8060        if triggerCount is not None:
 8061            return self.applyExtraneousEffect(
 8062                effect,
 8063                where=whichEffect[:2],
 8064                moveWhich=moveWhich
 8065            )
 8066        else:
 8067            return None
 8068
 8069    def applyExtraneousEffect(
 8070        self,
 8071        effect: base.Effect,
 8072        where: Optional[
 8073            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 8074        ] = None,
 8075        moveWhich: Optional[base.FocalPointName] = None,
 8076        challengePolicy: base.ChallengePolicy = "specified"
 8077    ) -> Optional[base.DecisionID]:
 8078        """
 8079        Applies a single extraneous effect to the state & graph,
 8080        *without* accounting for charges or delay values, since the
 8081        effect is not part of the graph (use `applyTransitionEffect` to
 8082        apply effects that are attached to transitions, which is almost
 8083        always the function you should be using). An associated
 8084        transition for the extraneous effect can be supplied using the
 8085        `where` argument, and effects like 'deactivate' and 'edit' will
 8086        affect it (but the effect's charges and delay values will still
 8087        be ignored).
 8088
 8089        If the effect would change the destination of a transition, the
 8090        altered destination ID is returned: 'bounce' effects return the
 8091        provided decision part of `where`, 'goto' effects return their
 8092        target, and 'follow' effects return the destination followed to
 8093        (possibly via chained follows in the extreme case). In all other
 8094        cases, `None` is returned indicating no change to a normal
 8095        destination.
 8096
 8097        If a specific focal point in a plural-focalized domain is
 8098        triggering the effect, the focal point name should be specified
 8099        via `moveWhich` so that goto `Effect`s can know which focal
 8100        point to move when it's not explicitly specified in the effect.
 8101        TODO: Test this!
 8102
 8103        Note that transition effects which update active decisions will
 8104        also update the exploration status of those decisions to
 8105        'exploring' if they had been in an unvisited status and will
 8106        remove any 'unconfirmed' tag they might still have (see
 8107        `updatePosition` and `hasBeenVisited`).
 8108
 8109        The given `challengePolicy` is applied when traversing further
 8110        transitions due to 'follow' effects.
 8111
 8112        Note: Anyone calling `applyExtraneousEffect` should update any
 8113        situation-based variables immediately after the call, as a
 8114        'revert' effect may have changed the current graph and/or state.
 8115        """
 8116        typ = effect['type']
 8117        value = effect['value']
 8118        applyTo = effect['applyTo']
 8119        inCommon = applyTo == 'common'
 8120
 8121        now = self.getSituation()
 8122
 8123        if where is not None:
 8124            if where[1] is not None:
 8125                searchFrom = now.graph.bothEnds(where[0], where[1])
 8126            else:
 8127                searchFrom = {now.graph.resolveDecision(where[0])}
 8128        else:
 8129            searchFrom = None
 8130
 8131        # Note: Delay and charges are ignored!
 8132
 8133        if typ in ("gain", "lose"):
 8134            value = cast(
 8135                Union[
 8136                    base.Capability,
 8137                    Tuple[base.Token, base.TokenCount],
 8138                    Tuple[Literal['skill'], base.Skill, base.Level],
 8139                ],
 8140                value
 8141            )
 8142            if isinstance(value, base.Capability):
 8143                if typ == "gain":
 8144                    self.gainCapabilityNow(value, inCommon)
 8145                else:
 8146                    self.loseCapabilityNow(value, inCommon)
 8147            elif len(value) == 2:  # must be a token, amount pair
 8148                token, amount = cast(
 8149                    Tuple[base.Token, base.TokenCount],
 8150                    value
 8151                )
 8152                if typ == "lose":
 8153                    amount *= -1
 8154                self.adjustTokensNow(token, amount, inCommon)
 8155            else:  # must be a 'skill', skill, level triple
 8156                _, skill, levels = cast(
 8157                    Tuple[Literal['skill'], base.Skill, base.Level],
 8158                    value
 8159                )
 8160                if typ == "lose":
 8161                    levels *= -1
 8162                self.adjustSkillLevelNow(skill, levels, inCommon)
 8163
 8164        elif typ == "set":
 8165            value = cast(
 8166                Union[
 8167                    Tuple[base.Token, base.TokenCount],
 8168                    Tuple[base.AnyMechanismSpecifier, base.MechanismState],
 8169                    Tuple[Literal['skill'], base.Skill, base.Level],
 8170                ],
 8171                value
 8172            )
 8173            if len(value) == 2:  # must be a token or mechanism pair
 8174                if isinstance(value[1], base.TokenCount):  # token
 8175                    token, amount = cast(
 8176                        Tuple[base.Token, base.TokenCount],
 8177                        value
 8178                    )
 8179                    self.setTokensNow(token, amount, inCommon)
 8180                else: # mechanism
 8181                    mechanism, state = cast(
 8182                        Tuple[
 8183                            base.AnyMechanismSpecifier,
 8184                            base.MechanismState
 8185                        ],
 8186                        value
 8187                    )
 8188                    self.setMechanismStateNow(mechanism, state, searchFrom)
 8189            else:  # must be a 'skill', skill, level triple
 8190                _, skill, level = cast(
 8191                    Tuple[Literal['skill'], base.Skill, base.Level],
 8192                    value
 8193                )
 8194                self.setSkillLevelNow(skill, level, inCommon)
 8195
 8196        elif typ == "toggle":
 8197            # Length-1 list just toggles a capability on/off based on current
 8198            # state (not attending to equivalents):
 8199            if isinstance(value, List):  # capabilities list
 8200                value = cast(List[base.Capability], value)
 8201                if len(value) == 0:
 8202                    raise ValueError(
 8203                        "Toggle effect has empty capabilities list."
 8204                    )
 8205                if len(value) == 1:
 8206                    capability = value[0]
 8207                    if self.hasCapability(capability, inCommon=False):
 8208                        self.loseCapabilityNow(capability, inCommon=False)
 8209                    else:
 8210                        self.gainCapabilityNow(capability)
 8211                else:
 8212                    # Otherwise toggle all powers off, then one on,
 8213                    # based on the first capability that's currently on.
 8214                    # Note we do NOT count equivalences.
 8215
 8216                    # Find first capability that's on:
 8217                    firstIndex: Optional[int] = None
 8218                    for i, capability in enumerate(value):
 8219                        if self.hasCapability(capability):
 8220                            firstIndex = i
 8221                            break
 8222
 8223                    # Turn them all off:
 8224                    for capability in value:
 8225                        self.loseCapabilityNow(capability, inCommon=False)
 8226                        # TODO: inCommon for the check?
 8227
 8228                    if firstIndex is None:
 8229                        self.gainCapabilityNow(value[0])
 8230                    else:
 8231                        self.gainCapabilityNow(
 8232                            value[(firstIndex + 1) % len(value)]
 8233                        )
 8234            else:  # must be a mechanism w/ states list
 8235                mechanism, states = cast(
 8236                    Tuple[
 8237                        base.AnyMechanismSpecifier,
 8238                        List[base.MechanismState]
 8239                    ],
 8240                    value
 8241                )
 8242                currentState = self.mechanismState(mechanism, where=searchFrom)
 8243                if len(states) == 1:
 8244                    if currentState == states[0]:
 8245                        # default alternate state
 8246                        self.setMechanismStateNow(
 8247                            mechanism,
 8248                            base.DEFAULT_MECHANISM_STATE,
 8249                            searchFrom
 8250                        )
 8251                    else:
 8252                        self.setMechanismStateNow(
 8253                            mechanism,
 8254                            states[0],
 8255                            searchFrom
 8256                        )
 8257                else:
 8258                    # Find our position in the list, if any
 8259                    try:
 8260                        currentIndex = states.index(cast(str, currentState))
 8261                        # Cast here just because we know that None will
 8262                        # raise a ValueError but we'll catch it, and we
 8263                        # want to suppress the mypy warning about the
 8264                        # option
 8265                    except ValueError:
 8266                        currentIndex = len(states) - 1
 8267                    # Set next state in list as current state
 8268                    nextIndex = (currentIndex + 1) % len(states)
 8269                    self.setMechanismStateNow(
 8270                        mechanism,
 8271                        states[nextIndex],
 8272                        searchFrom
 8273                    )
 8274
 8275        elif typ == "deactivate":
 8276            if where is None or where[1] is None:
 8277                raise ValueError(
 8278                    "Can't apply a deactivate effect without specifying"
 8279                    " which transition it applies to."
 8280                )
 8281
 8282            decision, transition = cast(
 8283                Tuple[base.AnyDecisionSpecifier, base.Transition],
 8284                where
 8285            )
 8286
 8287            dID = now.graph.resolveDecision(decision)
 8288            now.state['deactivated'].add((dID, transition))
 8289
 8290        elif typ == "edit":
 8291            value = cast(List[List[commands.Command]], value)
 8292            # If there are no blocks, do nothing
 8293            if len(value) > 0:
 8294                # Apply the first block of commands and then rotate the list
 8295                scope: commands.Scope = {}
 8296                if where is not None:
 8297                    here: base.DecisionID = now.graph.resolveDecision(
 8298                        where[0]
 8299                    )
 8300                    outwards: Optional[base.Transition] = where[1]
 8301                    scope['@'] = here
 8302                    scope['@t'] = outwards
 8303                    if outwards is not None:
 8304                        reciprocal = now.graph.getReciprocal(here, outwards)
 8305                        destination = now.graph.getDestination(here, outwards)
 8306                    else:
 8307                        reciprocal = None
 8308                        destination = None
 8309                    scope['@r'] = reciprocal
 8310                    scope['@d'] = destination
 8311                self.runCommandBlock(value[0], scope)
 8312                value.append(value.pop(0))
 8313
 8314        elif typ == "goto":
 8315            if isinstance(value, base.DecisionSpecifier):
 8316                target: base.AnyDecisionSpecifier = value
 8317                # use moveWhich provided as argument
 8318            elif isinstance(value, tuple):
 8319                target, moveWhich = cast(
 8320                    Tuple[base.AnyDecisionSpecifier, base.FocalPointName],
 8321                    value
 8322                )
 8323            else:
 8324                target = cast(base.AnyDecisionSpecifier, value)
 8325                # use moveWhich provided as argument
 8326
 8327            destID = now.graph.resolveDecision(target)
 8328            base.updatePosition(now, destID, applyTo, moveWhich)
 8329            return destID
 8330
 8331        elif typ == "bounce":
 8332            # Just need to let the caller know they should cancel
 8333            if where is None:
 8334                raise ValueError(
 8335                    "Can't apply a 'bounce' effect without a position"
 8336                    " to apply it from."
 8337                )
 8338            return now.graph.resolveDecision(where[0])
 8339
 8340        elif typ == "follow":
 8341            if where is None:
 8342                raise ValueError(
 8343                    f"Can't follow transition {value!r} because there"
 8344                    f" is no position information when applying the"
 8345                    f" effect."
 8346                )
 8347            if where[1] is not None:
 8348                followFrom = now.graph.getDestination(where[0], where[1])
 8349                if followFrom is None:
 8350                    raise ValueError(
 8351                        f"Can't follow transition {value!r} because the"
 8352                        f" position information specifies transition"
 8353                        f" {where[1]!r} from decision"
 8354                        f" {now.graph.identityOf(where[0])} but that"
 8355                        f" transition does not exist."
 8356                    )
 8357            else:
 8358                followFrom = now.graph.resolveDecision(where[0])
 8359
 8360            following = cast(base.Transition, value)
 8361
 8362            followTo = now.graph.getDestination(followFrom, following)
 8363
 8364            if followTo is None:
 8365                raise ValueError(
 8366                    f"Can't follow transition {following!r} because"
 8367                    f" that transition doesn't exist at the specified"
 8368                    f" destination {now.graph.identityOf(followFrom)}."
 8369                )
 8370
 8371            if self.isTraversable(followFrom, following):  # skip if not
 8372                # Perform initial position update before following new
 8373                # transition:
 8374                base.updatePosition(
 8375                    now,
 8376                    followFrom,
 8377                    applyTo,
 8378                    moveWhich
 8379                )
 8380
 8381                # Apply consequences of followed transition
 8382                fullFollowTo = self.applyTransitionConsequence(
 8383                    followFrom,
 8384                    following,
 8385                    moveWhich,
 8386                    challengePolicy
 8387                )
 8388
 8389                # Now update to end of followed transition
 8390                if fullFollowTo is None:
 8391                    base.updatePosition(
 8392                        now,
 8393                        followTo,
 8394                        applyTo,
 8395                        moveWhich
 8396                    )
 8397                    fullFollowTo = followTo
 8398
 8399                # Skip the normal update: we've taken care of that plus more
 8400                return fullFollowTo
 8401            else:
 8402                # Normal position updates still applies since follow
 8403                # transition wasn't possible
 8404                return None
 8405
 8406        elif typ == "save":
 8407            assert isinstance(value, base.SaveSlot)
 8408            now.saves[value] = copy.deepcopy((now.graph, now.state))
 8409
 8410        else:
 8411            raise ValueError(f"Invalid effect type {typ!r}.")
 8412
 8413        return None  # default return value if we didn't return above
 8414
 8415    def applyExtraneousConsequence(
 8416        self,
 8417        consequence: base.Consequence,
 8418        where: Optional[
 8419            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 8420        ] = None,
 8421        moveWhich: Optional[base.FocalPointName] = None
 8422    ) -> Optional[base.DecisionID]:
 8423        """
 8424        Applies an extraneous consequence not associated with a
 8425        transition. Unlike `applyTransitionConsequence`, the provided
 8426        `base.Consequence` must already have observed outcomes (see
 8427        `base.observeChallengeOutcomes`). Returns the decision ID for a
 8428        decision implied by a goto, follow, or bounce effect, or `None`
 8429        if no effect implies a destination.
 8430
 8431        The `where` and `moveWhich` optional arguments specify which
 8432        decision and/or transition to use as the application position,
 8433        and/or which focal point to move. This affects mechanism lookup
 8434        as well as the end position when 'follow' effects are used.
 8435        Specifically:
 8436
 8437        - A 'follow' trigger will search for transitions to follow from
 8438            the destination of the specified transition, or if only a
 8439            decision was supplied, from that decision.
 8440        - Mechanism lookups will start with both ends of the specified
 8441            transition as their search field (or with just the specified
 8442            decision if no transition is included).
 8443
 8444        'bounce' effects will cause an error unless position information
 8445        is provided, and will set the position to the base decision
 8446        provided in `where`.
 8447
 8448        Note: callers should update any situation-based variables
 8449        immediately after calling this as a 'revert' effect could change
 8450        the current graph and/or state and other changes could get lost
 8451        if they get applied to a stale graph/state.
 8452
 8453        # TODO: Examples for goto and follow effects.
 8454        """
 8455        now = self.getSituation()
 8456        searchFrom = set()
 8457        if where is not None:
 8458            if where[1] is not None:
 8459                searchFrom = now.graph.bothEnds(where[0], where[1])
 8460            else:
 8461                searchFrom = {now.graph.resolveDecision(where[0])}
 8462
 8463        context = base.RequirementContext(
 8464            state=now.state,
 8465            graph=now.graph,
 8466            searchFrom=searchFrom
 8467        )
 8468
 8469        effectIndices = base.observedEffects(context, consequence)
 8470        destID = None
 8471        for index in effectIndices:
 8472            effect = base.consequencePart(consequence, index)
 8473            if not isinstance(effect, dict) or 'value' not in effect:
 8474                raise RuntimeError(
 8475                    f"Invalid effect index {index}: Consequence part at"
 8476                    f" that index is not an Effect. Got:\n{effect}"
 8477                )
 8478            effect = cast(base.Effect, effect)
 8479            destID = self.applyExtraneousEffect(
 8480                effect,
 8481                where,
 8482                moveWhich
 8483            )
 8484            # technically this variable is not used later in this
 8485            # function, but the `applyExtraneousEffect` call means it
 8486            # needs an update, so we're doing that in case someone later
 8487            # adds code to this function that uses 'now' after this
 8488            # point.
 8489            now = self.getSituation()
 8490
 8491        return destID
 8492
 8493    def applyTransitionConsequence(
 8494        self,
 8495        decision: base.AnyDecisionSpecifier,
 8496        transition: base.AnyTransition,
 8497        moveWhich: Optional[base.FocalPointName] = None,
 8498        policy: base.ChallengePolicy = "specified",
 8499        fromIndex: Optional[int] = None,
 8500        toIndex: Optional[int] = None
 8501    ) -> Optional[base.DecisionID]:
 8502        """
 8503        Applies the effects of the specified transition to the current
 8504        graph and state, possibly overriding observed outcomes using
 8505        outcomes specified as part of a `base.TransitionWithOutcomes`.
 8506
 8507        The `where` and `moveWhich` function serve the same purpose as
 8508        for `applyExtraneousEffect`. If `where` is `None`, then the
 8509        effects will be applied as extraneous effects, meaning that
 8510        their delay and charges values will be ignored and their trigger
 8511        count will not be tracked. If `where` is supplied
 8512
 8513        Returns either None to indicate that the position update for the
 8514        transition should apply as usual, or a decision ID indicating
 8515        another destination which has already been applied by a
 8516        transition effect.
 8517
 8518        If `fromIndex` and/or `toIndex` are specified, then only effects
 8519        which have indices between those two (inclusive) will be
 8520        applied, and other effects will neither apply nor be updated in
 8521        any way. Note that `onlyPart` does not override the challenge
 8522        policy: if the effects in the specified part are not applied due
 8523        to a challenge outcome, they still won't happen, including
 8524        challenge outcomes outside of that part. Also, outcomes for
 8525        challenges of the entire consequence are re-observed if the
 8526        challenge policy implies it.
 8527
 8528        Note: Anyone calling this should update any situation-based
 8529        variables immediately after the call, as a 'revert' effect may
 8530        have changed the current graph and/or state.
 8531        """
 8532        now = self.getSituation()
 8533        dID = now.graph.resolveDecision(decision)
 8534
 8535        transitionName, outcomes = base.nameAndOutcomes(transition)
 8536
 8537        searchFrom = set()
 8538        searchFrom = now.graph.bothEnds(dID, transitionName)
 8539
 8540        context = base.RequirementContext(
 8541            state=now.state,
 8542            graph=now.graph,
 8543            searchFrom=searchFrom
 8544        )
 8545
 8546        consequence = now.graph.getConsequence(dID, transitionName)
 8547
 8548        # Make sure that challenge outcomes are known
 8549        if policy != "specified":
 8550            base.resetChallengeOutcomes(consequence)
 8551        useUp = outcomes[:]
 8552        base.observeChallengeOutcomes(
 8553            context,
 8554            consequence,
 8555            location=searchFrom,
 8556            policy=policy,
 8557            knownOutcomes=useUp
 8558        )
 8559        if len(useUp) > 0:
 8560            raise ValueError(
 8561                f"More outcomes specified than challenges observed in"
 8562                f" consequence:\n{consequence}"
 8563                f"\nRemaining outcomes:\n{useUp}"
 8564            )
 8565
 8566        # Figure out which effects apply, and apply each of them
 8567        effectIndices = base.observedEffects(context, consequence)
 8568        if fromIndex is None:
 8569            fromIndex = 0
 8570
 8571        altDest = None
 8572        for index in effectIndices:
 8573            if (
 8574                index >= fromIndex
 8575            and (toIndex is None or index <= toIndex)
 8576            ):
 8577                thisDest = self.applyTransitionEffect(
 8578                    (dID, transitionName, index),
 8579                    moveWhich
 8580                )
 8581                if thisDest is not None:
 8582                    altDest = thisDest
 8583                # TODO: What if this updates state with 'revert' to a
 8584                # graph that doesn't contain the same effects?
 8585                # TODO: Update 'now' and 'context'?!
 8586        return altDest
 8587
 8588    def allDecisions(self) -> List[base.DecisionID]:
 8589        """
 8590        Returns the list of all decisions which existed at any point
 8591        within the exploration. Example:
 8592
 8593        >>> ex = DiscreteExploration()
 8594        >>> ex.start('A')
 8595        0
 8596        >>> ex.observe('A', 'right')
 8597        1
 8598        >>> ex.explore('right', 'B', 'left')
 8599        1
 8600        >>> ex.observe('B', 'right')
 8601        2
 8602        >>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
 8603        [0, 1, 2]
 8604        """
 8605        seen = set()
 8606        result = []
 8607        for situation in self:
 8608            for decision in situation.graph:
 8609                if decision not in seen:
 8610                    result.append(decision)
 8611                    seen.add(decision)
 8612
 8613        return result
 8614
 8615    def allExploredDecisions(self) -> List[base.DecisionID]:
 8616        """
 8617        Returns the list of all decisions which existed at any point
 8618        within the exploration, excluding decisions whose highest
 8619        exploration status was `noticed` or lower. May still include
 8620        decisions which don't exist in the final situation's graph due to
 8621        things like decision merging. Example:
 8622
 8623        >>> ex = DiscreteExploration()
 8624        >>> ex.start('A')
 8625        0
 8626        >>> ex.observe('A', 'right')
 8627        1
 8628        >>> ex.explore('right', 'B', 'left')
 8629        1
 8630        >>> ex.observe('B', 'right')
 8631        2
 8632        >>> graph = ex.getSituation().graph
 8633        >>> graph.addDecision('C')  # add isolated decision; doesn't set status
 8634        3
 8635        >>> ex.hasBeenVisited('C')
 8636        False
 8637        >>> ex.allExploredDecisions()
 8638        [0, 1]
 8639        >>> ex.setExplorationStatus('C', 'exploring')
 8640        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
 8641        [0, 1, 3]
 8642        >>> ex.setExplorationStatus('A', 'explored')
 8643        >>> ex.allExploredDecisions()
 8644        [0, 1, 3]
 8645        >>> ex.setExplorationStatus('A', 'unknown')
 8646        >>> # remains visisted in an earlier step
 8647        >>> ex.allExploredDecisions()
 8648        [0, 1, 3]
 8649        >>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
 8650        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
 8651        [0, 1]
 8652        """
 8653        seen = set()
 8654        result = []
 8655        for situation in self:
 8656            graph = situation.graph
 8657            for decision in graph:
 8658                if (
 8659                    decision not in seen
 8660                and base.hasBeenVisited(situation, decision)
 8661                ):
 8662                    result.append(decision)
 8663                    seen.add(decision)
 8664
 8665        return result
 8666
 8667    def allVisitedDecisions(self) -> List[base.DecisionID]:
 8668        """
 8669        Returns the list of all decisions which existed at any point
 8670        within the exploration and which were visited at least once.
 8671        Orders them in the same order they were visited in.
 8672
 8673        Usually all of these decisions will be present in the final
 8674        situation's graph, but sometimes merging or other factors means
 8675        there might be some that won't be. Being present on the game
 8676        state's 'active' list in a step for its domain is what counts as
 8677        "being visited," which means that nodes which were passed through
 8678        directly via a 'follow' effect won't be counted, for example.
 8679
 8680        This should usually correspond with the absence of the
 8681        'unconfirmed' tag.
 8682
 8683        Example:
 8684
 8685        >>> ex = DiscreteExploration()
 8686        >>> ex.start('A')
 8687        0
 8688        >>> ex.observe('A', 'right')
 8689        1
 8690        >>> ex.explore('right', 'B', 'left')
 8691        1
 8692        >>> ex.observe('B', 'right')
 8693        2
 8694        >>> ex.getSituation().graph.addDecision('C')  # add isolated decision
 8695        3
 8696        >>> av = ex.allVisitedDecisions()
 8697        >>> av
 8698        [0, 1]
 8699        >>> all(  # no decisions in the 'visited' list are tagged
 8700        ...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
 8701        ...     for d in av
 8702        ... )
 8703        True
 8704        >>> graph = ex.getSituation().graph
 8705        >>> 'unconfirmed' in graph.decisionTags(0)
 8706        False
 8707        >>> 'unconfirmed' in graph.decisionTags(1)
 8708        False
 8709        >>> 'unconfirmed' in graph.decisionTags(2)
 8710        True
 8711        >>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
 8712        False
 8713        """
 8714        seen = set()
 8715        result = []
 8716        for step in range(len(self)):
 8717            active = self.getActiveDecisions(step)
 8718            for dID in active:
 8719                if dID not in seen:
 8720                    result.append(dID)
 8721                    seen.add(dID)
 8722
 8723        return result
 8724
 8725    def allTransitions(self) -> List[
 8726        Tuple[base.DecisionID, base.Transition, base.DecisionID]
 8727    ]:
 8728        """
 8729        Returns the list of all transitions which existed at any point
 8730        within the exploration, as 3-tuples with source decision ID,
 8731        transition name, and destination decision ID. Note that since
 8732        transitions can be deleted or re-targeted, and a transition name
 8733        can be re-used after being deleted, things can get messy in the
 8734        edges cases. When the same transition name is used in different
 8735        steps with different decision targets, we end up including each
 8736        possible source-transition-destination triple. Example:
 8737
 8738        >>> ex = DiscreteExploration()
 8739        >>> ex.start('A')
 8740        0
 8741        >>> ex.observe('A', 'right')
 8742        1
 8743        >>> ex.explore('right', 'B', 'left')
 8744        1
 8745        >>> ex.observe('B', 'right')
 8746        2
 8747        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
 8748        >>> ex.primaryDecision(0)
 8749        >>> ex.primaryDecision(1)
 8750        0
 8751        >>> ex.primaryDecision(2)
 8752        1
 8753        >>> ex.primaryDecision(3)
 8754        1
 8755        >>> len(ex)
 8756        4
 8757        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
 8758        >>> ex.observe('B', 'down')
 8759        3
 8760        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
 8761        >>> # (now-deleted), and the unnamed 'down from B'
 8762        >>> ex.allDecisions()
 8763        [0, 1, 2, 3]
 8764        >>> for tr in ex.allTransitions():
 8765        ...     print(tr)
 8766        ...
 8767        (0, 'right', 1)
 8768        (1, 'return', 0)
 8769        (1, 'left', 0)
 8770        (1, 'right', 2)
 8771        (2, 'return', 1)
 8772        (1, 'down', 3)
 8773        (3, 'return', 1)
 8774        >>> # Note transitions from now-deleted nodes, and 'return'
 8775        >>> # transitions for unexplored nodes before they get explored
 8776        """
 8777        seen = set()
 8778        result = []
 8779        for situation in self:
 8780            graph = situation.graph
 8781            for (src, dst, transition) in graph.allEdges():  # type:ignore
 8782                trans = (src, transition, dst)
 8783                if trans not in seen:
 8784                    result.append(trans)
 8785                    seen.add(trans)
 8786
 8787        return result
 8788
 8789    def start(
 8790        self,
 8791        decision: base.AnyDecisionSpecifier,
 8792        startCapabilities: Optional[base.CapabilitySet] = None,
 8793        setMechanismStates: Optional[
 8794            Dict[base.MechanismID, base.MechanismState]
 8795        ] = None,
 8796        setCustomState: Optional[dict] = None,
 8797        decisionType: base.DecisionType = "imposed"
 8798    ) -> base.DecisionID:
 8799        """
 8800        Sets the initial position information for a newly-relevant
 8801        domain for the current focal context. Creates a new decision
 8802        if the decision is specified by name or `DecisionSpecifier` and
 8803        that decision doesn't already exist. Returns the decision ID for
 8804        the newly-placed decision (or for the specified decision if it
 8805        already existed).
 8806
 8807        Raises a `BadStart` error if the current focal context already
 8808        has position information for the specified domain.
 8809
 8810        - The given `startCapabilities` replaces any existing
 8811            capabilities for the current focal context, although you can
 8812            leave it as the default `None` to avoid that and retain any
 8813            capabilities that have been set up already.
 8814        - The given `setMechanismStates` and `setCustomState`
 8815            dictionaries override all previous mechanism states & custom
 8816            states in the new situation. Leave these as the default
 8817            `None` to maintain those states.
 8818        - If created, the decision will be placed in the DEFAULT_DOMAIN
 8819            domain unless it's specified as a `base.DecisionSpecifier`
 8820            with a domain part, in which case that domain is used.
 8821        - If specified as a `base.DecisionSpecifier` with a zone part
 8822            and a new decision needs to be created, the decision will be
 8823            added to that zone, creating it at level 0 if necessary,
 8824            although otherwise no zone information will be changed.
 8825        - Resets the decision type to "pending" and the action taken to
 8826            `None`. Sets the decision type of the previous situation to
 8827            'imposed' (or the specified `decisionType`) and sets an
 8828            appropriate 'start' action for that situation.
 8829        - Tags the step with 'start'.
 8830        - Even in a plural- or spreading-focalized domain, you still need
 8831            to pick one decision to start at.
 8832        """
 8833        now = self.getSituation()
 8834
 8835        startID = now.graph.getDecision(decision)
 8836        zone = None
 8837        domain = base.DEFAULT_DOMAIN
 8838        if startID is None:
 8839            if isinstance(decision, base.DecisionID):
 8840                raise MissingDecisionError(
 8841                    f"Cannot start at decision {decision} because no"
 8842                    f" decision with that ID exists. Supply a name or"
 8843                    f" DecisionSpecifier if you need the start decision"
 8844                    f" to be created automatically."
 8845                )
 8846            elif isinstance(decision, base.DecisionName):
 8847                decision = base.DecisionSpecifier(
 8848                    domain=None,
 8849                    zone=None,
 8850                    name=decision
 8851                )
 8852            startID = now.graph.addDecision(
 8853                decision.name,
 8854                domain=decision.domain
 8855            )
 8856            zone = decision.zone
 8857            if decision.domain is not None:
 8858                domain = decision.domain
 8859
 8860        if zone is not None:
 8861            if now.graph.getZoneInfo(zone) is None:
 8862                now.graph.createZone(zone, 0)
 8863            now.graph.addDecisionToZone(startID, zone)
 8864
 8865        action: base.ExplorationAction = (
 8866            'start',
 8867            startID,
 8868            startID,
 8869            domain,
 8870            startCapabilities,
 8871            setMechanismStates,
 8872            setCustomState
 8873        )
 8874
 8875        self.advanceSituation(action, decisionType)
 8876
 8877        return startID
 8878
 8879    def hasBeenVisited(
 8880        self,
 8881        decision: base.AnyDecisionSpecifier,
 8882        step: int = -1
 8883    ):
 8884        """
 8885        Returns whether or not the specified decision has been visited in
 8886        the specified step (default current step).
 8887        """
 8888        return base.hasBeenVisited(self.getSituation(step), decision)
 8889
 8890    def setExplorationStatus(
 8891        self,
 8892        decision: base.AnyDecisionSpecifier,
 8893        status: base.ExplorationStatus,
 8894        upgradeOnly: bool = False
 8895    ):
 8896        """
 8897        Updates the current exploration status of a specific decision in
 8898        the current situation. If `upgradeOnly` is true (default is
 8899        `False` then the update will only apply if the new exploration
 8900        status counts as 'more-explored' than the old one (see
 8901        `base.moreExplored`).
 8902        """
 8903        base.setExplorationStatus(
 8904            self.getSituation(),
 8905            decision,
 8906            status,
 8907            upgradeOnly
 8908        )
 8909
 8910    def getExplorationStatus(
 8911        self,
 8912        decision: base.AnyDecisionSpecifier,
 8913        step: int = -1
 8914    ):
 8915        """
 8916        Returns the exploration status of the specified decision at the
 8917        specified step (default is last step). Decisions whose
 8918        exploration status has never been set will have a default status
 8919        of 'unknown'.
 8920        """
 8921        situation = self.getSituation(step)
 8922        dID = situation.graph.resolveDecision(decision)
 8923        return situation.state['exploration'].get(dID, 'unknown')
 8924
 8925    def deduceTransitionDetailsAtStep(
 8926        self,
 8927        step: int,
 8928        transition: base.Transition,
 8929        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 8930        whichFocus: Optional[base.FocalPointSpecifier] = None,
 8931        inCommon: Union[bool, Literal["auto"]] = "auto"
 8932    ) -> Tuple[
 8933        base.ContextSpecifier,
 8934        base.DecisionID,
 8935        base.DecisionID,
 8936        Optional[base.FocalPointSpecifier]
 8937    ]:
 8938        """
 8939        Given just a transition name which the player intends to take in
 8940        a specific step, deduces the `ContextSpecifier` for which
 8941        context should be updated, the source and destination
 8942        `DecisionID`s for the transition, and if the destination
 8943        decision's domain is plural-focalized, the `FocalPointName`
 8944        specifying which focal point should be moved.
 8945
 8946        Because many of those things are ambiguous, you may get an
 8947        `AmbiguousTransitionError` when things are underspecified, and
 8948        there are options for specifying some of the extra information
 8949        directly:
 8950
 8951        - `fromDecision` may be used to specify the source decision.
 8952        - `whichFocus` may be used to specify the focal point (within a
 8953            particular context/domain) being updated. When focal point
 8954            ambiguity remains and this is unspecified, the
 8955            alphabetically-earliest relevant focal point will be used
 8956            (either among all focal points which activate the source
 8957            decision, if there are any, or among all focal points for
 8958            the entire domain of the destination decision).
 8959        - `inCommon` (a `ContextSpecifier`) may be used to specify which
 8960            context to update. The default of "auto" will cause the
 8961            active context to be selected unless it does not activate
 8962            the source decision, in which case the common context will
 8963            be selected.
 8964
 8965        A `MissingDecisionError` will be raised if there are no current
 8966        active decisions (e.g., before `start` has been called), and a
 8967        `MissingTransitionError` will be raised if the listed transition
 8968        does not exist from any active decision (or from the specified
 8969        decision if `fromDecision` is used).
 8970        """
 8971        now = self.getSituation(step)
 8972        active = self.getActiveDecisions(step)
 8973        if len(active) == 0:
 8974            raise MissingDecisionError(
 8975                f"There are no active decisions from which transition"
 8976                f" {repr(transition)} could be taken at step {step}."
 8977            )
 8978
 8979        # All source/destination decision pairs for transitions with the
 8980        # given transition name.
 8981        allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {}
 8982
 8983        # TODO: When should we be trimming the active decisions to match
 8984        # any alterations to the graph?
 8985        for dID in active:
 8986            outgoing = now.graph.destinationsFrom(dID)
 8987            if transition in outgoing:
 8988                allDecisionPairs[dID] = outgoing[transition]
 8989
 8990        if len(allDecisionPairs) == 0:
 8991            raise MissingTransitionError(
 8992                f"No transitions named {repr(transition)} are outgoing"
 8993                f" from active decisions at step {step}."
 8994                f"\nActive decisions are:"
 8995                f"\n{now.graph.namesListing(active)}"
 8996            )
 8997
 8998        if (
 8999            fromDecision is not None
 9000        and fromDecision not in allDecisionPairs
 9001        ):
 9002            raise MissingTransitionError(
 9003                f"{fromDecision} was specified as the source decision"
 9004                f" for traversing transition {repr(transition)} but"
 9005                f" there is no transition of that name from that"
 9006                f" decision at step {step}."
 9007                f"\nValid source decisions are:"
 9008                f"\n{now.graph.namesListing(allDecisionPairs)}"
 9009            )
 9010        elif fromDecision is not None:
 9011            fromID = now.graph.resolveDecision(fromDecision)
 9012            destID = allDecisionPairs[fromID]
 9013            fromDomain = now.graph.domainFor(fromID)
 9014        elif len(allDecisionPairs) == 1:
 9015            fromID, destID = list(allDecisionPairs.items())[0]
 9016            fromDomain = now.graph.domainFor(fromID)
 9017        else:
 9018            fromID = None
 9019            destID = None
 9020            fromDomain = None
 9021            # Still ambiguous; resolve this below
 9022
 9023        # Use whichFocus if provided
 9024        if whichFocus is not None:
 9025            # Type/value check for whichFocus
 9026            if (
 9027                not isinstance(whichFocus, tuple)
 9028             or len(whichFocus) != 3
 9029             or whichFocus[0] not in ("active", "common")
 9030             or not isinstance(whichFocus[1], base.Domain)
 9031             or not isinstance(whichFocus[2], base.FocalPointName)
 9032            ):
 9033                raise ValueError(
 9034                    f"Invalid whichFocus value {repr(whichFocus)}."
 9035                    f"\nMust be a length-3 tuple with 'active' or 'common'"
 9036                    f" as the first element, a Domain as the second"
 9037                    f" element, and a FocalPointName as the third"
 9038                    f" element."
 9039                )
 9040
 9041            # Resolve focal point specified
 9042            fromID = base.resolvePosition(
 9043                now,
 9044                whichFocus
 9045            )
 9046            if fromID is None:
 9047                raise MissingTransitionError(
 9048                    f"Focal point {repr(whichFocus)} was specified as"
 9049                    f" the transition source, but that focal point does"
 9050                    f" not have a position."
 9051                )
 9052            else:
 9053                destID = now.graph.destination(fromID, transition)
 9054                fromDomain = now.graph.domainFor(fromID)
 9055
 9056        elif fromID is None:  # whichFocus is None, so it can't disambiguate
 9057            raise AmbiguousTransitionError(
 9058                f"Transition {repr(transition)} was selected for"
 9059                f" disambiguation, but there are multiple transitions"
 9060                f" with that name from currently-active decisions, and"
 9061                f" neither fromDecision nor whichFocus adequately"
 9062                f" disambiguates the specific transition taken."
 9063                f"\nValid source decisions at step {step} are:"
 9064                f"\n{now.graph.namesListing(allDecisionPairs)}"
 9065            )
 9066
 9067        # At this point, fromID, destID, and fromDomain have
 9068        # been resolved.
 9069        if fromID is None or destID is None or fromDomain is None:
 9070            raise RuntimeError(
 9071                f"One of fromID, destID, or fromDomain was None after"
 9072                f" disambiguation was finished:"
 9073                f"\nfromID: {fromID}, destID: {destID}, fromDomain:"
 9074                f" {repr(fromDomain)}"
 9075            )
 9076
 9077        # Now figure out which context activated the source so we know
 9078        # which focal point we're moving:
 9079        context = self.getActiveContext()
 9080        active = base.activeDecisionSet(context)
 9081        using: base.ContextSpecifier = "active"
 9082        if fromID not in active:
 9083            context = self.getCommonContext(step)
 9084            using = "common"
 9085
 9086        destDomain = now.graph.domainFor(destID)
 9087        if (
 9088            whichFocus is None
 9089        and base.getDomainFocalization(context, destDomain) == 'plural'
 9090        ):
 9091            # Need to figure out which focal point is moving; use the
 9092            # alphabetically earliest one that's positioned at the
 9093            # fromID, or just the earliest one overall if none of them
 9094            # are there.
 9095            contextFocalPoints: Dict[
 9096                base.FocalPointName,
 9097                Optional[base.DecisionID]
 9098            ] = cast(
 9099                Dict[base.FocalPointName, Optional[base.DecisionID]],
 9100                context['activeDecisions'][destDomain]
 9101            )
 9102            if not isinstance(contextFocalPoints, dict):
 9103                raise RuntimeError(
 9104                    f"Active decisions specifier for domain"
 9105                    f" {repr(destDomain)} with plural focalization has"
 9106                    f" a non-dictionary value."
 9107                )
 9108
 9109            if fromDomain == destDomain:
 9110                focalCandidates = [
 9111                    fp
 9112                    for fp, pos in contextFocalPoints.items()
 9113                    if pos == fromID
 9114                ]
 9115            else:
 9116                focalCandidates = list(contextFocalPoints)
 9117
 9118            whichFocus = (using, destDomain, min(focalCandidates))
 9119
 9120        # Now whichFocus has been set if it wasn't already specified;
 9121        # might still be None if it's not relevant.
 9122        return (using, fromID, destID, whichFocus)
 9123
 9124    def advanceSituation(
 9125        self,
 9126        action: base.ExplorationAction,
 9127        decisionType: base.DecisionType = "active",
 9128        challengePolicy: base.ChallengePolicy = "specified"
 9129    ) -> Tuple[base.Situation, Set[base.DecisionID]]:
 9130        """
 9131        Given an `ExplorationAction`, sets that as the action taken in
 9132        the current situation, and adds a new situation with the results
 9133        of that action. A `DoubleActionError` will be raised if the
 9134        current situation already has an action specified, and/or has a
 9135        decision type other than 'pending'. By default the type of the
 9136        decision will be 'active' but another `DecisionType` can be
 9137        specified via the `decisionType` parameter.
 9138
 9139        If the action specified is `('noAction',)`, then the new
 9140        situation will be a copy of the old one; this represents waiting
 9141        or being at an ending (a decision type other than 'pending'
 9142        should be used).
 9143
 9144        Although `None` can appear as the action entry in situations
 9145        with pending decisions, you cannot call `advanceSituation` with
 9146        `None` as the action.
 9147
 9148        If the action includes taking a transition whose requirements
 9149        are not satisfied, the transition will still be taken (and any
 9150        consequences applied) but a `TransitionBlockedWarning` will be
 9151        issued.
 9152
 9153        A `ChallengePolicy` may be specified, the default is 'specified'
 9154        which requires that outcomes are pre-specified. If any other
 9155        policy is set, the challenge outcomes will be reset before
 9156        re-resolving them according to the provided policy.
 9157
 9158        The new situation will have decision type 'pending' and `None`
 9159        as the action.
 9160
 9161        The new situation created as a result of the action is returned,
 9162        along with the set of destination decision IDs, including
 9163        possibly a modified destination via 'bounce', 'goto', and/or
 9164        'follow' effects. For actions that don't have a destination, the
 9165        second part of the returned tuple will be an empty set. Multiple
 9166        IDs may be in the set when using a start action in a plural- or
 9167        spreading-focalized domain, for example.
 9168
 9169        If the action updates active decisions (including via transition
 9170        effects) this will also update the exploration status of those
 9171        decisions to 'exploring' if they had been in an unvisited
 9172        status (see `updatePosition` and `hasBeenVisited`). This
 9173        includes decisions traveled through but not ultimately arrived
 9174        at via 'follow' effects.
 9175
 9176        If any decisions are active in the `ENDINGS_DOMAIN`, attempting
 9177        to 'warp', 'explore', 'take', or 'start' will raise an
 9178        `InvalidActionError`.
 9179        """
 9180        now = self.getSituation()
 9181        if now.type != 'pending' or now.action is not None:
 9182            raise DoubleActionError(
 9183                f"Attempted to take action {repr(action)} at step"
 9184                f" {len(self) - 1}, but an action and/or decision type"
 9185                f" had already been specified:"
 9186                f"\nAction: {repr(now.action)}"
 9187                f"\nType: {repr(now.type)}"
 9188            )
 9189
 9190        # Update the now situation to add in the decision type and
 9191        # action taken:
 9192        revised = base.Situation(
 9193            now.graph,
 9194            now.state,
 9195            decisionType,
 9196            action,
 9197            now.saves,
 9198            now.tags,
 9199            now.annotations
 9200        )
 9201        self.situations[-1] = revised
 9202
 9203        # Separate update process when reverting (this branch returns)
 9204        if (
 9205            action is not None
 9206        and isinstance(action, tuple)
 9207        and len(action) == 3
 9208        and action[0] == 'revertTo'
 9209        and isinstance(action[1], base.SaveSlot)
 9210        and isinstance(action[2], set)
 9211        and all(isinstance(x, str) for x in action[2])
 9212        ):
 9213            _, slot, aspects = action
 9214            if slot not in now.saves:
 9215                raise KeyError(
 9216                    f"Cannot load save slot {slot!r} because no save"
 9217                    f" data has been established for that slot."
 9218                )
 9219            load = now.saves[slot]
 9220            rGraph, rState = base.revertedState(
 9221                (now.graph, now.state),
 9222                load,
 9223                aspects
 9224            )
 9225            reverted = base.Situation(
 9226                graph=rGraph,
 9227                state=rState,
 9228                type='pending',
 9229                action=None,
 9230                saves=copy.deepcopy(now.saves),
 9231                tags={},
 9232                annotations=[]
 9233            )
 9234            self.situations.append(reverted)
 9235            # Apply any active triggers (edits reverted)
 9236            self.applyActiveTriggers()
 9237            # Figure out destinations set to return
 9238            newDestinations = set()
 9239            newPr = rState['primaryDecision']
 9240            if newPr is not None:
 9241                newDestinations.add(newPr)
 9242            return (reverted, newDestinations)
 9243
 9244        # TODO: These deep copies are expensive time-wise. Can we avoid
 9245        # them? Probably not.
 9246        newGraph = copy.deepcopy(now.graph)
 9247        newState = copy.deepcopy(now.state)
 9248        newSaves = copy.copy(now.saves)  # a shallow copy
 9249        newTags: Dict[base.Tag, base.TagValue] = {}
 9250        newAnnotations: List[base.Annotation] = []
 9251        updated = base.Situation(
 9252            graph=newGraph,
 9253            state=newState,
 9254            type='pending',
 9255            action=None,
 9256            saves=newSaves,
 9257            tags=newTags,
 9258            annotations=newAnnotations
 9259        )
 9260
 9261        targetContext: base.FocalContext
 9262
 9263        # Now that action effects have been imprinted into the updated
 9264        # situation, append it to our situations list
 9265        self.situations.append(updated)
 9266
 9267        # Figure out effects of the action:
 9268        if action is None:
 9269            raise InvalidActionError(
 9270                "None cannot be used as an action when advancing the"
 9271                " situation."
 9272            )
 9273
 9274        aLen = len(action)
 9275
 9276        destIDs = set()
 9277
 9278        if (
 9279            action[0] in ('start', 'take', 'explore', 'warp')
 9280        and any(
 9281                newGraph.domainFor(d) == ENDINGS_DOMAIN
 9282                for d in self.getActiveDecisions()
 9283            )
 9284        ):
 9285            activeEndings = [
 9286                d
 9287                for d in self.getActiveDecisions()
 9288                if newGraph.domainFor(d) == ENDINGS_DOMAIN
 9289            ]
 9290            raise InvalidActionError(
 9291                f"Attempted to {action[0]!r} while an ending was"
 9292                f" active. Active endings are:"
 9293                f"\n{newGraph.namesListing(activeEndings)}"
 9294            )
 9295
 9296        if action == ('noAction',):
 9297            # No updates needed
 9298            pass
 9299
 9300        elif (
 9301            not isinstance(action, tuple)
 9302         or (action[0] not in get_args(base.ExplorationActionType))
 9303         or not (2 <= aLen <= 7)
 9304        ):
 9305            raise InvalidActionError(
 9306                f"Invalid ExplorationAction tuple (must be a tuple that"
 9307                f" starts with an ExplorationActionType and has 2-6"
 9308                f" entries if it's not ('noAction',)):"
 9309                f"\n{repr(action)}"
 9310            )
 9311
 9312        elif action[0] == 'start':
 9313            (
 9314                _,
 9315                positionSpecifier,
 9316                primary,
 9317                domain,
 9318                capabilities,
 9319                mechanismStates,
 9320                customState
 9321            ) = cast(
 9322                Tuple[
 9323                    Literal['start'],
 9324                    Union[
 9325                        base.DecisionID,
 9326                        Dict[base.FocalPointName, base.DecisionID],
 9327                        Set[base.DecisionID]
 9328                    ],
 9329                    Optional[base.DecisionID],
 9330                    base.Domain,
 9331                    Optional[base.CapabilitySet],
 9332                    Optional[Dict[base.MechanismID, base.MechanismState]],
 9333                    Optional[dict]
 9334                ],
 9335                action
 9336            )
 9337            targetContext = newState['contexts'][
 9338                newState['activeContext']
 9339            ]
 9340
 9341            targetFocalization = base.getDomainFocalization(
 9342                targetContext,
 9343                domain
 9344            )  # sets up 'singular' as default if
 9345
 9346            # Check if there are any already-active decisions.
 9347            if targetContext['activeDecisions'][domain] is not None:
 9348                raise BadStart(
 9349                    f"Cannot start in domain {repr(domain)} because"
 9350                    f" that domain already has a position. 'start' may"
 9351                    f" only be used with domains that don't yet have"
 9352                    f" any position information."
 9353                )
 9354
 9355            # Make the domain active
 9356            if domain not in targetContext['activeDomains']:
 9357                targetContext['activeDomains'].add(domain)
 9358
 9359            # Check position info matches focalization type and update
 9360            # exploration statuses
 9361            if isinstance(positionSpecifier, base.DecisionID):
 9362                if targetFocalization != 'singular':
 9363                    raise BadStart(
 9364                        f"Invalid position specifier"
 9365                        f" {repr(positionSpecifier)} (type"
 9366                        f" {type(positionSpecifier)}). Domain"
 9367                        f" {repr(domain)} has {targetFocalization}"
 9368                        f" focalization."
 9369                    )
 9370                base.setExplorationStatus(
 9371                    updated,
 9372                    positionSpecifier,
 9373                    'exploring',
 9374                    upgradeOnly=True
 9375                )
 9376                destIDs.add(positionSpecifier)
 9377            elif isinstance(positionSpecifier, dict):
 9378                if targetFocalization != 'plural':
 9379                    raise BadStart(
 9380                        f"Invalid position specifier"
 9381                        f" {repr(positionSpecifier)} (type"
 9382                        f" {type(positionSpecifier)}). Domain"
 9383                        f" {repr(domain)} has {targetFocalization}"
 9384                        f" focalization."
 9385                    )
 9386                destIDs |= set(positionSpecifier.values())
 9387            elif isinstance(positionSpecifier, set):
 9388                if targetFocalization != 'spreading':
 9389                    raise BadStart(
 9390                        f"Invalid position specifier"
 9391                        f" {repr(positionSpecifier)} (type"
 9392                        f" {type(positionSpecifier)}). Domain"
 9393                        f" {repr(domain)} has {targetFocalization}"
 9394                        f" focalization."
 9395                    )
 9396                destIDs |= positionSpecifier
 9397            else:
 9398                raise TypeError(
 9399                    f"Invalid position specifier"
 9400                    f" {repr(positionSpecifier)} (type"
 9401                    f" {type(positionSpecifier)}). It must be a"
 9402                    f" DecisionID, a dictionary from FocalPointNames to"
 9403                    f" DecisionIDs, or a set of DecisionIDs, according"
 9404                    f" to the focalization of the relevant domain."
 9405                )
 9406
 9407            # Put specified position(s) in place
 9408            # TODO: This cast is really silly...
 9409            targetContext['activeDecisions'][domain] = cast(
 9410                Union[
 9411                    None,
 9412                    base.DecisionID,
 9413                    Dict[base.FocalPointName, Optional[base.DecisionID]],
 9414                    Set[base.DecisionID]
 9415                ],
 9416                positionSpecifier
 9417            )
 9418
 9419            # Set primary decision
 9420            newState['primaryDecision'] = primary
 9421
 9422            # Set capabilities
 9423            if capabilities is not None:
 9424                targetContext['capabilities'] = capabilities
 9425
 9426            # Set mechanism states
 9427            if mechanismStates is not None:
 9428                newState['mechanisms'] = mechanismStates
 9429
 9430            # Set custom state
 9431            if customState is not None:
 9432                newState['custom'] = customState
 9433
 9434        elif action[0] in ('explore', 'take', 'warp'):  # similar handling
 9435            assert (
 9436                len(action) == 3
 9437             or len(action) == 4
 9438             or len(action) == 6
 9439             or len(action) == 7
 9440            )
 9441            # Set up necessary variables
 9442            cSpec: base.ContextSpecifier = "active"
 9443            fromID: Optional[base.DecisionID] = None
 9444            takeTransition: Optional[base.Transition] = None
 9445            outcomes: List[bool] = []
 9446            destID: base.DecisionID  # No starting value as it's not optional
 9447            moveInDomain: Optional[base.Domain] = None
 9448            moveWhich: Optional[base.FocalPointName] = None
 9449
 9450            # Figure out target context
 9451            if isinstance(action[1], str):
 9452                if action[1] not in get_args(base.ContextSpecifier):
 9453                    raise InvalidActionError(
 9454                        f"Action specifies {repr(action[1])} context,"
 9455                        f" but that's not a valid context specifier."
 9456                        f" The valid options are:"
 9457                        f"\n{repr(get_args(base.ContextSpecifier))}"
 9458                    )
 9459                else:
 9460                    cSpec = cast(base.ContextSpecifier, action[1])
 9461            else:  # Must be a `FocalPointSpecifier`
 9462                cSpec, moveInDomain, moveWhich = cast(
 9463                    base.FocalPointSpecifier,
 9464                    action[1]
 9465                )
 9466                assert moveInDomain is not None
 9467
 9468            # Grab target context to work in
 9469            if cSpec == 'common':
 9470                targetContext = newState['common']
 9471            else:
 9472                targetContext = newState['contexts'][
 9473                    newState['activeContext']
 9474                ]
 9475
 9476            # Check focalization of the target domain
 9477            if moveInDomain is not None:
 9478                fType = base.getDomainFocalization(
 9479                    targetContext,
 9480                    moveInDomain
 9481                )
 9482                if (
 9483                    (
 9484                        isinstance(action[1], str)
 9485                    and fType == 'plural'
 9486                    ) or (
 9487                        not isinstance(action[1], str)
 9488                    and fType != 'plural'
 9489                    )
 9490                ):
 9491                    raise ImpossibleActionError(
 9492                        f"Invalid ExplorationAction (moves in"
 9493                        f" plural-focalized domains must include a"
 9494                        f" FocalPointSpecifier, while moves in"
 9495                        f" non-plural-focalized domains must not."
 9496                        f" Domain {repr(moveInDomain)} is"
 9497                        f" {fType}-focalized):"
 9498                        f"\n{repr(action)}"
 9499                    )
 9500
 9501            if action[0] == "warp":
 9502                # It's a warp, so destination is specified directly
 9503                if not isinstance(action[2], base.DecisionID):
 9504                    raise TypeError(
 9505                        f"Invalid ExplorationAction tuple (third part"
 9506                        f" must be a decision ID for 'warp' actions):"
 9507                        f"\n{repr(action)}"
 9508                    )
 9509                else:
 9510                    destID = cast(base.DecisionID, action[2])
 9511
 9512            elif aLen == 4 or aLen == 7:
 9513                # direct 'take' or 'explore'
 9514                fromID = cast(base.DecisionID, action[2])
 9515                takeTransition, outcomes = cast(
 9516                    base.TransitionWithOutcomes,
 9517                    action[3]  # type: ignore [misc]
 9518                )
 9519                if (
 9520                    not isinstance(fromID, base.DecisionID)
 9521                 or not isinstance(takeTransition, base.Transition)
 9522                ):
 9523                    raise InvalidActionError(
 9524                        f"Invalid ExplorationAction tuple (for 'take' or"
 9525                        f" 'explore', if the length is 4/7, parts 2-4"
 9526                        f" must be a context specifier, a decision ID, and a"
 9527                        f" transition name. Got:"
 9528                        f"\n{repr(action)}"
 9529                    )
 9530
 9531                try:
 9532                    destID = newGraph.destination(fromID, takeTransition)
 9533                except MissingDecisionError:
 9534                    raise ImpossibleActionError(
 9535                        f"Invalid ExplorationAction: move from decision"
 9536                        f" {fromID} is invalid because there is no"
 9537                        f" decision with that ID in the current"
 9538                        f" graph."
 9539                        f"\nValid decisions are:"
 9540                        f"\n{newGraph.namesListing(newGraph)}"
 9541                    )
 9542                except MissingTransitionError:
 9543                    valid = newGraph.destinationsFrom(fromID)
 9544                    listing = newGraph.destinationsListing(valid)
 9545                    raise ImpossibleActionError(
 9546                        f"Invalid ExplorationAction: move from decision"
 9547                        f" {newGraph.identityOf(fromID)}"
 9548                        f" along transition {repr(takeTransition)} is"
 9549                        f" invalid because there is no such transition"
 9550                        f" at that decision."
 9551                        f"\nValid transitions there are:"
 9552                        f"\n{listing}"
 9553                    )
 9554                targetActive = targetContext['activeDecisions']
 9555                if moveInDomain is not None:
 9556                    activeInDomain = targetActive[moveInDomain]
 9557                    if (
 9558                        (
 9559                            isinstance(activeInDomain, base.DecisionID)
 9560                        and fromID != activeInDomain
 9561                        )
 9562                     or (
 9563                            isinstance(activeInDomain, set)
 9564                        and fromID not in activeInDomain
 9565                        )
 9566                     or (
 9567                            isinstance(activeInDomain, dict)
 9568                        and fromID not in activeInDomain.values()
 9569                        )
 9570                    ):
 9571                        raise ImpossibleActionError(
 9572                            f"Invalid ExplorationAction: move from"
 9573                            f" decision {fromID} is invalid because"
 9574                            f" that decision is not active in domain"
 9575                            f" {repr(moveInDomain)} in the current"
 9576                            f" graph."
 9577                            f"\nValid decisions are:"
 9578                            f"\n{newGraph.namesListing(newGraph)}"
 9579                        )
 9580
 9581            elif aLen == 3 or aLen == 6:
 9582                # 'take' or 'explore' focal point
 9583                # We know that moveInDomain is not None here.
 9584                assert moveInDomain is not None
 9585                if not isinstance(action[2], base.Transition):
 9586                    raise InvalidActionError(
 9587                        f"Invalid ExplorationAction tuple (for 'take'"
 9588                        f" actions if the second part is a"
 9589                        f" FocalPointSpecifier the third part must be a"
 9590                        f" transition name):"
 9591                        f"\n{repr(action)}"
 9592                    )
 9593
 9594                takeTransition, outcomes = cast(
 9595                    base.TransitionWithOutcomes,
 9596                    action[2]
 9597                )
 9598                targetActive = targetContext['activeDecisions']
 9599                activeInDomain = cast(
 9600                    Dict[base.FocalPointName, Optional[base.DecisionID]],
 9601                    targetActive[moveInDomain]
 9602                )
 9603                if (
 9604                    moveInDomain is not None
 9605                and (
 9606                        not isinstance(activeInDomain, dict)
 9607                     or moveWhich not in activeInDomain
 9608                    )
 9609                ):
 9610                    raise ImpossibleActionError(
 9611                        f"Invalid ExplorationAction: move of focal"
 9612                        f" point {repr(moveWhich)} in domain"
 9613                        f" {repr(moveInDomain)} is invalid because"
 9614                        f" that domain does not have a focal point"
 9615                        f" with that name."
 9616                    )
 9617                fromID = activeInDomain[moveWhich]
 9618                if fromID is None:
 9619                    raise ImpossibleActionError(
 9620                        f"Invalid ExplorationAction: move of focal"
 9621                        f" point {repr(moveWhich)} in domain"
 9622                        f" {repr(moveInDomain)} is invalid because"
 9623                        f" that focal point does not have a position"
 9624                        f" at this step."
 9625                    )
 9626                try:
 9627                    destID = newGraph.destination(fromID, takeTransition)
 9628                except MissingDecisionError:
 9629                    raise ImpossibleActionError(
 9630                        f"Invalid exploration state: focal point"
 9631                        f" {repr(moveWhich)} in domain"
 9632                        f" {repr(moveInDomain)} specifies decision"
 9633                        f" {fromID} as the current position, but"
 9634                        f" that decision does not exist!"
 9635                    )
 9636                except MissingTransitionError:
 9637                    valid = newGraph.destinationsFrom(fromID)
 9638                    listing = newGraph.destinationsListing(valid)
 9639                    raise ImpossibleActionError(
 9640                        f"Invalid ExplorationAction: move of focal"
 9641                        f" point {repr(moveWhich)} in domain"
 9642                        f" {repr(moveInDomain)} along transition"
 9643                        f" {repr(takeTransition)} is invalid because"
 9644                        f" that focal point is at decision"
 9645                        f" {newGraph.identityOf(fromID)} and that"
 9646                        f" decision does not have an outgoing"
 9647                        f" transition with that name.\nValid"
 9648                        f" transitions from that decision are:"
 9649                        f"\n{listing}"
 9650                    )
 9651
 9652            else:
 9653                raise InvalidActionError(
 9654                    f"Invalid ExplorationAction: unrecognized"
 9655                    f" 'explore', 'take' or 'warp' format:"
 9656                    f"\n{action}"
 9657                )
 9658
 9659            # If we're exploring, update information for the destination
 9660            if action[0] == 'explore':
 9661                zone = cast(Optional[base.Zone], action[-1])
 9662                recipName = cast(Optional[base.Transition], action[-2])
 9663                destOrName = cast(
 9664                    Union[base.DecisionName, base.DecisionID, None],
 9665                    action[-3]
 9666                )
 9667                if isinstance(destOrName, base.DecisionID):
 9668                    destID = destOrName
 9669
 9670                if fromID is None or takeTransition is None:
 9671                    raise ImpossibleActionError(
 9672                        f"Invalid ExplorationAction: exploration"
 9673                        f" has unclear origin decision or transition."
 9674                        f" Got:\n{action}"
 9675                    )
 9676
 9677                currentDest = newGraph.destination(fromID, takeTransition)
 9678                if not newGraph.isConfirmed(currentDest):
 9679                    newGraph.replaceUnconfirmed(
 9680                        fromID,
 9681                        takeTransition,
 9682                        destOrName,
 9683                        recipName,
 9684                        placeInZone=zone,
 9685                        forceNew=not isinstance(destOrName, base.DecisionID)
 9686                    )
 9687                else:
 9688                    # Otherwise, since the destination already existed
 9689                    # and was hooked up at the right decision, no graph
 9690                    # edits need to be made, unless we need to rename
 9691                    # the reciprocal.
 9692                    # TODO: Do we care about zones here?
 9693                    if recipName is not None:
 9694                        oldReciprocal = newGraph.getReciprocal(
 9695                            fromID,
 9696                            takeTransition
 9697                        )
 9698                        if (
 9699                            oldReciprocal is not None
 9700                        and oldReciprocal != recipName
 9701                        ):
 9702                            newGraph.addTransition(
 9703                                destID,
 9704                                recipName,
 9705                                fromID,
 9706                                None
 9707                            )
 9708                            newGraph.setReciprocal(
 9709                                destID,
 9710                                recipName,
 9711                                takeTransition,
 9712                                setBoth=True
 9713                            )
 9714                            newGraph.mergeTransitions(
 9715                                destID,
 9716                                oldReciprocal,
 9717                                recipName
 9718                            )
 9719
 9720            # If we are moving along a transition, check requirements
 9721            # and apply transition effects *before* updating our
 9722            # position, and check that they don't cancel the normal
 9723            # position update
 9724            finalDest = None
 9725            if takeTransition is not None:
 9726                assert fromID is not None  # both or neither
 9727                if not self.isTraversable(fromID, takeTransition):
 9728                    req = now.graph.getTransitionRequirement(
 9729                        fromID,
 9730                        takeTransition
 9731                    )
 9732                    # TODO: Alter warning message if transition is
 9733                    # deactivated vs. requirement not satisfied
 9734                    warnings.warn(
 9735                        (
 9736                            f"The requirements for transition"
 9737                            f" {takeTransition!r} from decision"
 9738                            f" {now.graph.identityOf(fromID)} are"
 9739                            f" not met at step {len(self) - 1} (or that"
 9740                            f" transition has been deactivated):\n{req}"
 9741                        ),
 9742                        TransitionBlockedWarning
 9743                    )
 9744
 9745                # Apply transition consequences to our new state and
 9746                # figure out if we need to skip our normal update or not
 9747                finalDest = self.applyTransitionConsequence(
 9748                    fromID,
 9749                    (takeTransition, outcomes),
 9750                    moveWhich,
 9751                    challengePolicy
 9752                )
 9753
 9754            # Check moveInDomain
 9755            destDomain = newGraph.domainFor(destID)
 9756            if moveInDomain is not None and moveInDomain != destDomain:
 9757                raise ImpossibleActionError(
 9758                    f"Invalid ExplorationAction: move specified"
 9759                    f" domain {repr(moveInDomain)} as the domain of"
 9760                    f" the focal point to move, but the destination"
 9761                    f" of the move is {now.graph.identityOf(destID)}"
 9762                    f" which is in domain {repr(destDomain)}, so focal"
 9763                    f" point {repr(moveWhich)} cannot be moved there."
 9764                )
 9765
 9766            # Now that we know where we're going, update position
 9767            # information (assuming it wasn't already set):
 9768            if finalDest is None:
 9769                finalDest = destID
 9770                base.updatePosition(
 9771                    updated,
 9772                    destID,
 9773                    cSpec,
 9774                    moveWhich
 9775                )
 9776
 9777            destIDs.add(finalDest)
 9778
 9779        elif action[0] == "focus":
 9780            # Figure out target context
 9781            action = cast(
 9782                Tuple[
 9783                    Literal['focus'],
 9784                    base.ContextSpecifier,
 9785                    Set[base.Domain],
 9786                    Set[base.Domain]
 9787                ],
 9788                action
 9789            )
 9790            contextSpecifier: base.ContextSpecifier = action[1]
 9791            if contextSpecifier == 'common':
 9792                targetContext = newState['common']
 9793            else:
 9794                targetContext = newState['contexts'][
 9795                    newState['activeContext']
 9796                ]
 9797
 9798            # Just need to swap out active domains
 9799            goingOut, comingIn = cast(
 9800                Tuple[Set[base.Domain], Set[base.Domain]],
 9801                action[2:]
 9802            )
 9803            if (
 9804                not isinstance(goingOut, set)
 9805             or not isinstance(comingIn, set)
 9806             or not all(isinstance(d, base.Domain) for d in goingOut)
 9807             or not all(isinstance(d, base.Domain) for d in comingIn)
 9808            ):
 9809                raise InvalidActionError(
 9810                    f"Invalid ExplorationAction tuple (must have 4"
 9811                    f" parts if the first part is 'focus' and"
 9812                    f" the third and fourth parts must be sets of"
 9813                    f" domains):"
 9814                    f"\n{repr(action)}"
 9815                )
 9816            activeSet = targetContext['activeDomains']
 9817            for dom in goingOut:
 9818                try:
 9819                    activeSet.remove(dom)
 9820                except KeyError:
 9821                    warnings.warn(
 9822                        (
 9823                            f"Domain {repr(dom)} was deactivated at"
 9824                            f" step {len(self)} but it was already"
 9825                            f" inactive at that point."
 9826                        ),
 9827                        InactiveDomainWarning
 9828                    )
 9829            # TODO: Also warn for doubly-activated domains?
 9830            activeSet |= comingIn
 9831
 9832            # destIDs remains empty in this case
 9833
 9834        elif action[0] == 'swap':  # update which `FocalContext` is active
 9835            newContext = cast(base.FocalContextName, action[1])
 9836            if newContext not in newState['contexts']:
 9837                raise MissingFocalContextError(
 9838                    f"'swap' action with target {repr(newContext)} is"
 9839                    f" invalid because no context with that name"
 9840                    f" exists."
 9841                )
 9842            newState['activeContext'] = newContext
 9843
 9844            # destIDs remains empty in this case
 9845
 9846        elif action[0] == 'focalize':  # create new `FocalContext`
 9847            newContext = cast(base.FocalContextName, action[1])
 9848            if newContext in newState['contexts']:
 9849                raise FocalContextCollisionError(
 9850                    f"'focalize' action with target {repr(newContext)}"
 9851                    f" is invalid because a context with that name"
 9852                    f" already exists."
 9853                )
 9854            newState['contexts'][newContext] = base.emptyFocalContext()
 9855            newState['activeContext'] = newContext
 9856
 9857            # destIDs remains empty in this case
 9858
 9859        # revertTo is handled above
 9860        else:
 9861            raise InvalidActionError(
 9862                f"Invalid ExplorationAction tuple (first item must be"
 9863                f" an ExplorationActionType, and tuple must be length-1"
 9864                f" if the action type is 'noAction'):"
 9865                f"\n{repr(action)}"
 9866            )
 9867
 9868        # Apply any active triggers
 9869        followTo = self.applyActiveTriggers()
 9870        if followTo is not None:
 9871            destIDs.add(followTo)
 9872            # TODO: Re-work to work with multiple position updates in
 9873            # different focal contexts, domains, and/or for different
 9874            # focal points in plural-focalized domains.
 9875
 9876        return (updated, destIDs)
 9877
 9878    def applyActiveTriggers(self) -> Optional[base.DecisionID]:
 9879        """
 9880        Finds all actions with the 'trigger' tag attached to currently
 9881        active decisions, and applies their effects if their requirements
 9882        are met (ordered by decision-ID with ties broken alphabetically
 9883        by action name).
 9884
 9885        'bounce', 'goto' and 'follow' effects may apply. However, any
 9886        new triggers that would be activated because of decisions
 9887        reached by such effects will not apply. Note that 'bounce'
 9888        effects update position to the decision where the action was
 9889        attached, which is usually a no-op. This function returns the
 9890        decision ID of the decision reached by the last decision-moving
 9891        effect applied, or `None` if no such effects triggered.
 9892
 9893        TODO: What about situations where positions are updated in
 9894        multiple domains or multiple foal points in a plural domain are
 9895        independently updated?
 9896
 9897        TODO: Tests for this!
 9898        """
 9899        active = self.getActiveDecisions()
 9900        now = self.getSituation()
 9901        graph = now.graph
 9902        finalFollow = None
 9903        for decision in sorted(active):
 9904            for action in graph.decisionActions(decision):
 9905                if (
 9906                    'trigger' in graph.transitionTags(decision, action)
 9907                and self.isTraversable(decision, action)
 9908                ):
 9909                    followTo = self.applyTransitionConsequence(
 9910                        decision,
 9911                        action
 9912                    )
 9913                    if followTo is not None:
 9914                        # TODO: How will triggers interact with
 9915                        # plural-focalized domains? Probably need to fix
 9916                        # this to detect moveWhich based on which focal
 9917                        # points are at the decision where the transition
 9918                        # is, and then apply this to each of them?
 9919                        base.updatePosition(now, followTo)
 9920                        finalFollow = followTo
 9921
 9922        return finalFollow
 9923
 9924    def explore(
 9925        self,
 9926        transition: base.AnyTransition,
 9927        destination: Union[base.DecisionName, base.DecisionID, None],
 9928        reciprocal: Optional[base.Transition] = None,
 9929        zone: Optional[base.Zone] = base.DefaultZone,
 9930        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9931        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9932        inCommon: Union[bool, Literal["auto"]] = "auto",
 9933        decisionType: base.DecisionType = "active",
 9934        challengePolicy: base.ChallengePolicy = "specified"
 9935    ) -> base.DecisionID:
 9936        """
 9937        Adds a new situation to the exploration representing the
 9938        traversal of the specified transition (possibly with outcomes
 9939        specified for challenges among that transitions consequences).
 9940        Uses `deduceTransitionDetailsAtStep` to figure out from the
 9941        transition name which specific transition is taken (and which
 9942        focal point is updated if necessary). This uses the
 9943        `fromDecision`, `whichFocus`, and `inCommon` optional
 9944        parameters, and also determines whether to update the common or
 9945        the active `FocalContext`. Sets the exploration status of the
 9946        decision explored to 'exploring'. Returns the decision ID for
 9947        the destination reached, accounting for goto/bounce/follow
 9948        effects that might have triggered.
 9949
 9950        The `destination` will be used to name the newly-explored
 9951        decision, except when it's a `DecisionID`, in which case that
 9952        decision must be unvisited, and we'll connect the specified
 9953        transition to that decision.
 9954
 9955        The focalization of the destination domain in the context to be
 9956        updated determines how active decisions are changed:
 9957
 9958        - If the destination domain is focalized as 'single', then in
 9959            the subsequent `Situation`, the destination decision will
 9960            become the single active decision in that domain.
 9961        - If it's focalized as 'plural', then one of the
 9962            `FocalPointName`s for that domain will be moved to activate
 9963            that decision; which one can be specified using `whichFocus`
 9964            or if left unspecified, will be deduced: if the starting
 9965            decision is in the same domain, then the
 9966            alphabetically-earliest focal point which is at the starting
 9967            decision will be moved. If the starting position is in a
 9968            different domain, then the alphabetically earliest focal
 9969            point among all focal points in the destination domain will
 9970            be moved.
 9971        - If it's focalized as 'spreading', then the destination
 9972            decision will be added to the set of active decisions in
 9973            that domain, without removing any.
 9974
 9975        The transition named must have been pointing to an unvisited
 9976        decision (see `hasBeenVisited`), and the name of that decision
 9977        will be updated if a `destination` value is given (a
 9978        `DecisionCollisionWarning` will be issued if the destination
 9979        name is a duplicate of another name in the graph, although this
 9980        is not an error). Additionally:
 9981
 9982        - If a `reciprocal` name is specified, the reciprocal transition
 9983            will be renamed using that name, or created with that name if
 9984            it didn't already exist. If reciprocal is left as `None` (the
 9985            default) then no change will be made to the reciprocal
 9986            transition, and it will not be created if it doesn't exist.
 9987        - If a `zone` is specified, the newly-explored decision will be
 9988            added to that zone (and that zone will be created at level 0
 9989            if it didn't already exist). If `zone` is set to `None` then
 9990            it will not be added to any new zones. If `zone` is left as
 9991            the default (the `base.DefaultZone` value) then the explored
 9992            decision will be added to each zone that the decision it was
 9993            explored from is a part of. If a zone needs to be created,
 9994            that zone will be added as a sub-zone of each zone which is a
 9995            parent of a zone that directly contains the origin decision.
 9996        - An `ExplorationStatusError` will be raised if the specified
 9997            transition leads to a decision whose `ExplorationStatus` is
 9998            'exploring' or higher (i.e., `hasBeenVisited`). (Use
 9999            `returnTo` instead to adjust things when a transition to an
10000            unknown destination turns out to lead to an already-known
10001            destination.)
10002        - A `TransitionBlockedWarning` will be issued if the specified
10003            transition is not traversable given the current game state
10004            (but in that last case the step will still be taken).
10005        - By default, the decision type for the new step will be
10006            'active', but a `decisionType` value can be specified to
10007            override that.
10008        - By default, the 'mostLikely' `ChallengePolicy` will be used to
10009            resolve challenges in the consequence of the transition
10010            taken, but an alternate policy can be supplied using the
10011            `challengePolicy` argument.
10012        """
10013        now = self.getSituation()
10014
10015        transitionName, outcomes = base.nameAndOutcomes(transition)
10016
10017        # Deduce transition details from the name + optional specifiers
10018        (
10019            using,
10020            fromID,
10021            destID,
10022            whichFocus
10023        ) = self.deduceTransitionDetailsAtStep(
10024            -1,
10025            transitionName,
10026            fromDecision,
10027            whichFocus,
10028            inCommon
10029        )
10030
10031        # Issue a warning if the destination name is already in use
10032        if destination is not None:
10033            if isinstance(destination, base.DecisionName):
10034                try:
10035                    existingID = now.graph.resolveDecision(destination)
10036                    collision = existingID != destID
10037                except MissingDecisionError:
10038                    collision = False
10039                except AmbiguousDecisionSpecifierError:
10040                    collision = True
10041
10042                if collision and WARN_OF_NAME_COLLISIONS:
10043                    warnings.warn(
10044                        (
10045                            f"The destination name {repr(destination)} is"
10046                            f" already in use when exploring transition"
10047                            f" {repr(transition)} from decision"
10048                            f" {now.graph.identityOf(fromID)} at step"
10049                            f" {len(self) - 1}."
10050                        ),
10051                        DecisionCollisionWarning
10052                    )
10053
10054        # TODO: Different terminology for "exploration state above
10055        # noticed" vs. "DG thinks it's been visited"...
10056        if (
10057            self.hasBeenVisited(destID)
10058        ):
10059            raise ExplorationStatusError(
10060                f"Cannot explore to decision"
10061                f" {now.graph.identityOf(destID)} because it has"
10062                f" already been visited. Use returnTo instead of"
10063                f" explore when discovering a connection back to a"
10064                f" previously-explored decision."
10065            )
10066
10067        if (
10068            isinstance(destination, base.DecisionID)
10069        and self.hasBeenVisited(destination)
10070        ):
10071            raise ExplorationStatusError(
10072                f"Cannot explore to decision"
10073                f" {now.graph.identityOf(destination)} because it has"
10074                f" already been visited. Use returnTo instead of"
10075                f" explore when discovering a connection back to a"
10076                f" previously-explored decision."
10077            )
10078
10079        actionTaken: base.ExplorationAction = (
10080            'explore',
10081            using,
10082            fromID,
10083            (transitionName, outcomes),
10084            destination,
10085            reciprocal,
10086            zone
10087        )
10088        if whichFocus is not None:
10089            # A move-from-specific-focal-point action
10090            actionTaken = (
10091                'explore',
10092                whichFocus,
10093                (transitionName, outcomes),
10094                destination,
10095                reciprocal,
10096                zone
10097            )
10098
10099        # Advance the situation, applying transition effects and
10100        # updating the destination decision.
10101        _, finalDest = self.advanceSituation(
10102            actionTaken,
10103            decisionType,
10104            challengePolicy
10105        )
10106
10107        # TODO: Is this assertion always valid?
10108        assert len(finalDest) == 1
10109        return next(x for x in finalDest)
10110
10111    def returnTo(
10112        self,
10113        transition: base.AnyTransition,
10114        destination: base.AnyDecisionSpecifier,
10115        reciprocal: Optional[base.Transition] = None,
10116        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10117        whichFocus: Optional[base.FocalPointSpecifier] = None,
10118        inCommon: Union[bool, Literal["auto"]] = "auto",
10119        decisionType: base.DecisionType = "active",
10120        challengePolicy: base.ChallengePolicy = "specified"
10121    ) -> base.DecisionID:
10122        """
10123        Adds a new graph to the exploration that replaces the given
10124        transition at the current position (which must lead to an unknown
10125        node, or a `MissingDecisionError` will result). The new
10126        transition will connect back to the specified destination, which
10127        must already exist (or a different `ValueError` will be raised).
10128        Returns the decision ID for the destination reached.
10129
10130        Deduces transition details using the optional `fromDecision`,
10131        `whichFocus`, and `inCommon` arguments in addition to the
10132        `transition` value; see `deduceTransitionDetailsAtStep`.
10133
10134        If a `reciprocal` transition is specified, that transition must
10135        either not already exist in the destination decision or lead to
10136        an unknown region; it will be replaced (or added) as an edge
10137        leading back to the current position.
10138
10139        The `decisionType` and `challengePolicy` optional arguments are
10140        used for `advanceSituation`.
10141
10142        A `TransitionBlockedWarning` will be issued if the requirements
10143        for the transition are not met, but the step will still be taken.
10144        Raises a `MissingDecisionError` if there is no current
10145        transition.
10146        """
10147        now = self.getSituation()
10148
10149        transitionName, outcomes = base.nameAndOutcomes(transition)
10150
10151        # Deduce transition details from the name + optional specifiers
10152        (
10153            using,
10154            fromID,
10155            destID,
10156            whichFocus
10157        ) = self.deduceTransitionDetailsAtStep(
10158            -1,
10159            transitionName,
10160            fromDecision,
10161            whichFocus,
10162            inCommon
10163        )
10164
10165        # Replace with connection to existing destination
10166        destID = now.graph.resolveDecision(destination)
10167        if not self.hasBeenVisited(destID):
10168            raise ExplorationStatusError(
10169                f"Cannot return to decision"
10170                f" {now.graph.identityOf(destID)} because it has NOT"
10171                f" already been at least partially explored. Use"
10172                f" explore instead of returnTo when discovering a"
10173                f" connection to a previously-unexplored decision."
10174            )
10175
10176        now.graph.replaceUnconfirmed(
10177            fromID,
10178            transitionName,
10179            destID,
10180            reciprocal
10181        )
10182
10183        # A move-from-decision action
10184        actionTaken: base.ExplorationAction = (
10185            'take',
10186            using,
10187            fromID,
10188            (transitionName, outcomes)
10189        )
10190        if whichFocus is not None:
10191            # A move-from-specific-focal-point action
10192            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10193
10194        # Next, advance the situation, applying transition effects
10195        _, finalDest = self.advanceSituation(
10196            actionTaken,
10197            decisionType,
10198            challengePolicy
10199        )
10200
10201        assert len(finalDest) == 1
10202        return next(x for x in finalDest)
10203
10204    def takeAction(
10205        self,
10206        action: base.AnyTransition,
10207        requires: Optional[base.Requirement] = None,
10208        consequence: Optional[base.Consequence] = None,
10209        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10210        whichFocus: Optional[base.FocalPointSpecifier] = None,
10211        inCommon: Union[bool, Literal["auto"]] = "auto",
10212        decisionType: base.DecisionType = "active",
10213        challengePolicy: base.ChallengePolicy = "specified"
10214    ) -> base.DecisionID:
10215        """
10216        Adds a new graph to the exploration based on taking the given
10217        action, which must be a self-transition in the graph. If the
10218        action does not already exist in the graph, it will be created.
10219        Either way if requirements and/or a consequence are supplied,
10220        the requirements and consequence of the action will be updated
10221        to match them, and those are the requirements/consequence that
10222        will count.
10223
10224        Returns the decision ID for the decision reached, which normally
10225        is the same action you were just at, but which might be altered
10226        by goto, bounce, and/or follow effects.
10227
10228        Issues a `TransitionBlockedWarning` if the current game state
10229        doesn't satisfy the requirements for the action.
10230
10231        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
10232        used for `deduceTransitionDetailsAtStep`, while `decisionType`
10233        and `challengePolicy` are used for `advanceSituation`.
10234
10235        When an action is being created, `fromDecision` (or
10236        `whichFocus`) must be specified, since the source decision won't
10237        be deducible from the transition name. Note that if a transition
10238        with the given name exists from *any* active decision, it will
10239        be used instead of creating a new action (possibly resulting in
10240        an error if it's not a self-loop transition). Also, you may get
10241        an `AmbiguousTransitionError` if several transitions with that
10242        name exist; in that case use `fromDecision` and/or `whichFocus`
10243        to disambiguate.
10244        """
10245        now = self.getSituation()
10246        graph = now.graph
10247
10248        actionName, outcomes = base.nameAndOutcomes(action)
10249
10250        try:
10251            (
10252                using,
10253                fromID,
10254                destID,
10255                whichFocus
10256            ) = self.deduceTransitionDetailsAtStep(
10257                -1,
10258                actionName,
10259                fromDecision,
10260                whichFocus,
10261                inCommon
10262            )
10263
10264            if destID != fromID:
10265                raise ValueError(
10266                    f"Cannot take action {repr(action)} because it's a"
10267                    f" transition to another decision, not an action"
10268                    f" (use explore, returnTo, and/or retrace instead)."
10269                )
10270
10271        except MissingTransitionError:
10272            using = 'active'
10273            if inCommon is True:
10274                using = 'common'
10275
10276            if fromDecision is not None:
10277                fromID = graph.resolveDecision(fromDecision)
10278            elif whichFocus is not None:
10279                maybeFromID = base.resolvePosition(now, whichFocus)
10280                if maybeFromID is None:
10281                    raise MissingDecisionError(
10282                        f"Focal point {repr(whichFocus)} was specified"
10283                        f" in takeAction but that focal point doesn't"
10284                        f" have a position."
10285                    )
10286                else:
10287                    fromID = maybeFromID
10288            else:
10289                raise AmbiguousTransitionError(
10290                    f"Taking action {repr(action)} is ambiguous because"
10291                    f" the source decision has not been specified via"
10292                    f" either fromDecision or whichFocus, and we"
10293                    f" couldn't find an existing action with that name."
10294                )
10295
10296            # Since the action doesn't exist, add it:
10297            graph.addAction(fromID, actionName, requires, consequence)
10298
10299        # Update the transition requirement/consequence if requested
10300        # (before the action is taken)
10301        if requires is not None:
10302            graph.setTransitionRequirement(fromID, actionName, requires)
10303        if consequence is not None:
10304            graph.setConsequence(fromID, actionName, consequence)
10305
10306        # A move-from-decision action
10307        actionTaken: base.ExplorationAction = (
10308            'take',
10309            using,
10310            fromID,
10311            (actionName, outcomes)
10312        )
10313        if whichFocus is not None:
10314            # A move-from-specific-focal-point action
10315            actionTaken = ('take', whichFocus, (actionName, outcomes))
10316
10317        _, finalDest = self.advanceSituation(
10318            actionTaken,
10319            decisionType,
10320            challengePolicy
10321        )
10322
10323        assert len(finalDest) in (0, 1)
10324        if len(finalDest) == 1:
10325            return next(x for x in finalDest)
10326        else:
10327            return fromID
10328
10329    def retrace(
10330        self,
10331        transition: base.AnyTransition,
10332        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10333        whichFocus: Optional[base.FocalPointSpecifier] = None,
10334        inCommon: Union[bool, Literal["auto"]] = "auto",
10335        decisionType: base.DecisionType = "active",
10336        challengePolicy: base.ChallengePolicy = "specified"
10337    ) -> base.DecisionID:
10338        """
10339        Adds a new graph to the exploration based on taking the given
10340        transition, which must already exist and which must not lead to
10341        an unknown region. Returns the ID of the destination decision,
10342        accounting for goto, bounce, and/or follow effects.
10343
10344        Issues a `TransitionBlockedWarning` if the current game state
10345        doesn't satisfy the requirements for the transition.
10346
10347        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
10348        used for `deduceTransitionDetailsAtStep`, while `decisionType`
10349        and `challengePolicy` are used for `advanceSituation`.
10350        """
10351        now = self.getSituation()
10352
10353        transitionName, outcomes = base.nameAndOutcomes(transition)
10354
10355        (
10356            using,
10357            fromID,
10358            destID,
10359            whichFocus
10360        ) = self.deduceTransitionDetailsAtStep(
10361            -1,
10362            transitionName,
10363            fromDecision,
10364            whichFocus,
10365            inCommon
10366        )
10367
10368        visited = self.hasBeenVisited(destID)
10369        confirmed = now.graph.isConfirmed(destID)
10370        if not confirmed:
10371            raise ExplorationStatusError(
10372                f"Cannot retrace transition {transition!r} from"
10373                f" decision {now.graph.identityOf(fromID)} because it"
10374                f" leads to an unconfirmed decision.\nUse"
10375                f" `DiscreteExploration.explore` and provide"
10376                f" destination decision details instead."
10377            )
10378        if not visited:
10379            raise ExplorationStatusError(
10380                f"Cannot retrace transition {transition!r} from"
10381                f" decision {now.graph.identityOf(fromID)} because it"
10382                f" leads to an unvisited decision.\nUse"
10383                f" `DiscreteExploration.explore` and provide"
10384                f" destination decision details instead."
10385            )
10386
10387        # A move-from-decision action
10388        actionTaken: base.ExplorationAction = (
10389            'take',
10390            using,
10391            fromID,
10392            (transitionName, outcomes)
10393        )
10394        if whichFocus is not None:
10395            # A move-from-specific-focal-point action
10396            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10397
10398        _, finalDest = self.advanceSituation(
10399            actionTaken,
10400            decisionType,
10401        challengePolicy
10402    )
10403
10404        assert len(finalDest) == 1
10405        return next(x for x in finalDest)
10406
10407    def warp(
10408        self,
10409        destination: base.AnyDecisionSpecifier,
10410        consequence: Optional[base.Consequence] = None,
10411        domain: Optional[base.Domain] = None,
10412        zone: Optional[base.Zone] = base.DefaultZone,
10413        whichFocus: Optional[base.FocalPointSpecifier] = None,
10414        inCommon: Union[bool] = False,
10415        decisionType: base.DecisionType = "active",
10416        challengePolicy: base.ChallengePolicy = "specified"
10417    ) -> base.DecisionID:
10418        """
10419        Adds a new graph to the exploration that's a copy of the current
10420        graph, with the position updated to be at the destination without
10421        actually creating a transition from the old position to the new
10422        one. Returns the ID of the decision warped to (accounting for
10423        any goto or follow effects triggered).
10424
10425        Any provided consequences are applied, but are not associated
10426        with any transition (so any delays and charges are ignored, and
10427        'bounce' effects don't actually cancel the warp). 'goto' or
10428        'follow' effects might change the warp destination; 'follow'
10429        effects take the original destination as their starting point.
10430        Any mechanisms mentioned in extra consequences will be found
10431        based on the destination. Outcomes in supplied challenges should
10432        be pre-specified, or else they will be resolved with the
10433        `challengePolicy`.
10434
10435        `whichFocus` may be specified when the destination domain's
10436        focalization is 'plural' but for 'singular' or 'spreading'
10437        destination domains it is not allowed. `inCommon` determines
10438        whether the common or the active focal context is updated
10439        (default is to update the active context). The `decisionType`
10440        and `challengePolicy` are used for `advanceSituation`.
10441
10442        - If the destination did not already exist, it will be created.
10443            Initially, it will be disconnected from all other decisions.
10444            In this case, the `domain` value can be used to put it in a
10445            non-default domain.
10446        - The position is set to the specified destination, and if a
10447            `consequence` is specified it is applied. Note that
10448            'deactivate' effects are NOT allowed, and 'edit' effects
10449            must establish their own transition target because there is
10450            no transition that the effects are being applied to.
10451        - If the destination had been unexplored, its exploration status
10452            will be set to 'exploring'.
10453        - If a `zone` is specified, the destination will be added to that
10454            zone (even if the destination already existed) and that zone
10455            will be created (as a level-0 zone) if need be. If `zone` is
10456            set to `None`, then no zone will be applied. If `zone` is
10457            left as the default (`base.DefaultZone`) and the
10458            focalization of the destination domain is 'singular' or
10459            'plural' and the destination is newly created and there is
10460            an origin and the origin is in the same domain as the
10461            destination, then the destination will be added to all zones
10462            that the origin was a part of if the destination is newly
10463            created, but otherwise the destination will not be added to
10464            any zones. If the specified zone has to be created and
10465            there's an origin decision, it will be added as a sub-zone
10466            to all parents of zones directly containing the origin, as
10467            long as the origin is in the same domain as the destination.
10468        """
10469        now = self.getSituation()
10470        graph = now.graph
10471
10472        fromID: Optional[base.DecisionID]
10473
10474        new = False
10475        try:
10476            destID = graph.resolveDecision(destination)
10477        except MissingDecisionError:
10478            if isinstance(destination, tuple):
10479                # just the name; ignore zone/domain
10480                destination = destination[-1]
10481
10482            if not isinstance(destination, base.DecisionName):
10483                raise TypeError(
10484                    f"Warp destination {repr(destination)} does not"
10485                    f" exist, and cannot be created as it is not a"
10486                    f" decision name."
10487                )
10488            destID = graph.addDecision(destination, domain)
10489            graph.tagDecision(destID, 'unconfirmed')
10490            self.setExplorationStatus(destID, 'unknown')
10491            new = True
10492
10493        using: base.ContextSpecifier
10494        if inCommon:
10495            targetContext = self.getCommonContext()
10496            using = "common"
10497        else:
10498            targetContext = self.getActiveContext()
10499            using = "active"
10500
10501        destDomain = graph.domainFor(destID)
10502        targetFocalization = base.getDomainFocalization(
10503            targetContext,
10504            destDomain
10505        )
10506        if targetFocalization == 'singular':
10507            targetActive = targetContext['activeDecisions']
10508            if destDomain in targetActive:
10509                fromID = cast(
10510                    base.DecisionID,
10511                    targetContext['activeDecisions'][destDomain]
10512                )
10513            else:
10514                fromID = None
10515        elif targetFocalization == 'plural':
10516            if whichFocus is None:
10517                raise AmbiguousTransitionError(
10518                    f"Warping to {repr(destination)} is ambiguous"
10519                    f" becuase domain {repr(destDomain)} has plural"
10520                    f" focalization, and no whichFocus value was"
10521                    f" specified."
10522                )
10523
10524            fromID = base.resolvePosition(
10525                self.getSituation(),
10526                whichFocus
10527            )
10528        else:
10529            fromID = None
10530
10531        # Handle zones
10532        if zone == base.DefaultZone:
10533            if (
10534                new
10535            and fromID is not None
10536            and graph.domainFor(fromID) == destDomain
10537            ):
10538                for prevZone in graph.zoneParents(fromID):
10539                    graph.addDecisionToZone(destination, prevZone)
10540            # Otherwise don't update zones
10541        elif zone is not None:
10542            # Newness is ignored when a zone is specified
10543            zone = cast(base.Zone, zone)
10544            # Create the zone at level 0 if it didn't already exist
10545            if graph.getZoneInfo(zone) is None:
10546                graph.createZone(zone, 0)
10547                # Add the newly created zone to each 2nd-level parent of
10548                # the previous decision if there is one and it's in the
10549                # same domain
10550                if (
10551                    fromID is not None
10552                and graph.domainFor(fromID) == destDomain
10553                ):
10554                    for prevZone in graph.zoneParents(fromID):
10555                        for prevUpper in graph.zoneParents(prevZone):
10556                            graph.addZoneToZone(zone, prevUpper)
10557            # Finally add the destination to the (maybe new) zone
10558            graph.addDecisionToZone(destID, zone)
10559        # else don't touch zones
10560
10561        # Encode the action taken
10562        actionTaken: base.ExplorationAction
10563        if whichFocus is None:
10564            actionTaken = (
10565                'warp',
10566                using,
10567                destID
10568            )
10569        else:
10570            actionTaken = (
10571                'warp',
10572                whichFocus,
10573                destID
10574            )
10575
10576        # Advance the situation
10577        _, finalDests = self.advanceSituation(
10578            actionTaken,
10579            decisionType,
10580            challengePolicy
10581        )
10582        now = self.getSituation()  # updating just in case
10583
10584        assert len(finalDests) == 1
10585        finalDest = next(x for x in finalDests)
10586
10587        # Apply additional consequences:
10588        if consequence is not None:
10589            altDest = self.applyExtraneousConsequence(
10590                consequence,
10591                where=(destID, None),
10592                # TODO: Mechanism search from both ends?
10593                moveWhich=(
10594                    whichFocus[-1]
10595                    if whichFocus is not None
10596                    else None
10597                )
10598            )
10599            if altDest is not None:
10600                finalDest = altDest
10601            now = self.getSituation()  # updating just in case
10602
10603        return finalDest
10604
10605    def wait(
10606        self,
10607        consequence: Optional[base.Consequence] = None,
10608        decisionType: base.DecisionType = "active",
10609        challengePolicy: base.ChallengePolicy = "specified"
10610    ) -> Optional[base.DecisionID]:
10611        """
10612        Adds a wait step. If a consequence is specified, it is applied,
10613        although it will not have any position/transition information
10614        available during resolution/application.
10615
10616        A decision type other than "active" and/or a challenge policy
10617        other than "specified" can be included (see `advanceSituation`).
10618
10619        The "pending" decision type may not be used, a `ValueError` will
10620        result. This allows None as the action for waiting while
10621        preserving the pending/None type/action combination for
10622        unresolved situations.
10623
10624        If a goto or follow effect in the applied consequence implies a
10625        position update, this will return the new destination ID;
10626        otherwise it will return `None`. Triggering a 'bounce' effect
10627        will be an error, because there is no position information for
10628        the effect.
10629        """
10630        if decisionType == "pending":
10631            raise ValueError(
10632                "The 'pending' decision type may not be used for"
10633                " wait actions."
10634            )
10635        self.advanceSituation(('noAction',), decisionType, challengePolicy)
10636        now = self.getSituation()
10637        if consequence is not None:
10638            if challengePolicy != "specified":
10639                base.resetChallengeOutcomes(consequence)
10640            observed = base.observeChallengeOutcomes(
10641                base.RequirementContext(
10642                    state=now.state,
10643                    graph=now.graph,
10644                    searchFrom=set()
10645                ),
10646                consequence,
10647                location=None,  # No position info
10648                policy=challengePolicy,
10649                knownOutcomes=None  # bake outcomes into the consequence
10650            )
10651            # No location information since we might have multiple
10652            # active decisions and there's no indication of which one
10653            # we're "waiting at."
10654            finalDest = self.applyExtraneousConsequence(observed)
10655            now = self.getSituation()  # updating just in case
10656
10657            return finalDest
10658        else:
10659            return None
10660
10661    def revert(
10662        self,
10663        slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT,
10664        aspects: Optional[Set[str]] = None,
10665        decisionType: base.DecisionType = "active"
10666    ) -> None:
10667        """
10668        Reverts the game state to a previously-saved game state (saved
10669        via a 'save' effect). The save slot name and set of aspects to
10670        revert are required. By default, all aspects except the graph
10671        are reverted.
10672        """
10673        if aspects is None:
10674            aspects = set()
10675
10676        action: base.ExplorationAction = ("revertTo", slot, aspects)
10677
10678        self.advanceSituation(action, decisionType)
10679
10680    def observeAll(
10681        self,
10682        where: base.AnyDecisionSpecifier,
10683        *transitions: Union[
10684            base.Transition,
10685            Tuple[base.Transition, base.AnyDecisionSpecifier],
10686            Tuple[
10687                base.Transition,
10688                base.AnyDecisionSpecifier,
10689                base.Transition
10690            ]
10691        ]
10692    ) -> List[base.DecisionID]:
10693        """
10694        Observes one or more new transitions, applying changes to the
10695        current graph. The transitions can be specified in one of three
10696        ways:
10697
10698        1. A transition name. The transition will be created and will
10699            point to a new unexplored node.
10700        2. A pair containing a transition name and a destination
10701            specifier. If the destination does not exist it will be
10702            created as an unexplored node, although in that case the
10703            decision specifier may not be an ID.
10704        3. A triple containing a transition name, a destination
10705            specifier, and a reciprocal name. Works the same as the pair
10706            case but also specifies the name for the reciprocal
10707            transition.
10708
10709        The new transitions are outgoing from specified decision.
10710
10711        Yields the ID of each decision connected to, whether those are
10712        new or existing decisions.
10713        """
10714        now = self.getSituation()
10715        fromID = now.graph.resolveDecision(where)
10716        result = []
10717        for entry in transitions:
10718            if isinstance(entry, base.Transition):
10719                result.append(self.observe(fromID, entry))
10720            else:
10721                result.append(self.observe(fromID, *entry))
10722        return result
10723
10724    def observe(
10725        self,
10726        where: base.AnyDecisionSpecifier,
10727        transition: base.Transition,
10728        destination: Optional[base.AnyDecisionSpecifier] = None,
10729        reciprocal: Optional[base.Transition] = None
10730    ) -> base.DecisionID:
10731        """
10732        Observes a single new outgoing transition from the specified
10733        decision. If specified the transition connects to a specific
10734        destination and/or has a specific reciprocal. The specified
10735        destination will be created if it doesn't exist, or where no
10736        destination is specified, a new unexplored decision will be
10737        added. The ID of the decision connected to is returned.
10738
10739        Sets the exploration status of the observed destination to
10740        "noticed" if a destination is specified and needs to be created
10741        (but not when no destination is specified).
10742
10743        For example:
10744
10745        >>> e = DiscreteExploration()
10746        >>> e.start('start')
10747        0
10748        >>> e.observe('start', 'up')
10749        1
10750        >>> g = e.getSituation().graph
10751        >>> g.destinationsFrom('start')
10752        {'up': 1}
10753        >>> e.getExplorationStatus(1)  # not given a name: assumed unknown
10754        'unknown'
10755        >>> e.observe('start', 'left', 'A')
10756        2
10757        >>> g.destinationsFrom('start')
10758        {'up': 1, 'left': 2}
10759        >>> g.nameFor(2)
10760        'A'
10761        >>> e.getExplorationStatus(2)  # given a name: noticed
10762        'noticed'
10763        >>> e.observe('start', 'up2', 1)
10764        1
10765        >>> g.destinationsFrom('start')
10766        {'up': 1, 'left': 2, 'up2': 1}
10767        >>> e.getExplorationStatus(1)  # existing decision: status unchanged
10768        'unknown'
10769        >>> e.observe('start', 'right', 'B', 'left')
10770        3
10771        >>> g.destinationsFrom('start')
10772        {'up': 1, 'left': 2, 'up2': 1, 'right': 3}
10773        >>> g.nameFor(3)
10774        'B'
10775        >>> e.getExplorationStatus(3)  # new + name -> noticed
10776        'noticed'
10777        >>> e.observe('start', 'right')  # repeat transition name
10778        Traceback (most recent call last):
10779        ...
10780        exploration.core.TransitionCollisionError...
10781        >>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
10782        Traceback (most recent call last):
10783        ...
10784        exploration.core.TransitionCollisionError...
10785        >>> g = e.getSituation().graph
10786        >>> g.createZone('Z', 0)
10787        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
10788 annotations=[])
10789        >>> g.addDecisionToZone('start', 'Z')
10790        >>> e.observe('start', 'down', 'C', 'up')
10791        4
10792        >>> g.destinationsFrom('start')
10793        {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
10794        >>> g.identityOf('C')
10795        '4 (C)'
10796        >>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
10797        set()
10798        >>> e.observe(
10799        ...     'C',
10800        ...     'right',
10801        ...     base.DecisionSpecifier('main', 'Z2', 'D'),
10802        ... )  # creates zone
10803        5
10804        >>> g.destinationsFrom('C')
10805        {'up': 0, 'right': 5}
10806        >>> g.destinationsFrom('D')  # default reciprocal name
10807        {'return': 4}
10808        >>> g.identityOf('D')
10809        '5 (Z2::D)'
10810        >>> g.zoneParents(5)
10811        {'Z2'}
10812        """
10813        now = self.getSituation()
10814        fromID = now.graph.resolveDecision(where)
10815
10816        kwargs: Dict[
10817            str,
10818            Union[base.Transition, base.DecisionName, None]
10819        ] = {}
10820        if reciprocal is not None:
10821            kwargs['reciprocal'] = reciprocal
10822
10823        if destination is not None:
10824            try:
10825                destID = now.graph.resolveDecision(destination)
10826                now.graph.addTransition(
10827                    fromID,
10828                    transition,
10829                    destID,
10830                    reciprocal
10831                )
10832                return destID
10833            except MissingDecisionError:
10834                if isinstance(destination, base.DecisionSpecifier):
10835                    kwargs['toDomain'] = destination.domain
10836                    kwargs['placeInZone'] = destination.zone
10837                    kwargs['destinationName'] = destination.name
10838                elif isinstance(destination, base.DecisionName):
10839                    kwargs['destinationName'] = destination
10840                else:
10841                    assert isinstance(destination, base.DecisionID)
10842                    # We got to except by failing to resolve, so it's an
10843                    # invalid ID
10844                    raise
10845
10846        result = now.graph.addUnexploredEdge(
10847            fromID,
10848            transition,
10849            **kwargs  # type: ignore [arg-type]
10850        )
10851        if 'destinationName' in kwargs:
10852            self.setExplorationStatus(result, 'noticed', upgradeOnly=True)
10853        return result
10854
10855    def observeMechanisms(
10856        self,
10857        where: Optional[base.AnyDecisionSpecifier],
10858        *mechanisms: Union[
10859            base.MechanismName,
10860            Tuple[base.MechanismName, base.MechanismState]
10861        ]
10862    ) -> List[base.MechanismID]:
10863        """
10864        Adds one or more mechanisms to the exploration's current graph,
10865        located at the specified decision. Global mechanisms can be
10866        added by using `None` for the location. Mechanisms are named, or
10867        a (name, state) tuple can be used to set them into a specific
10868        state. Mechanisms not set to a state will be in the
10869        `base.DEFAULT_MECHANISM_STATE`.
10870        """
10871        now = self.getSituation()
10872        result = []
10873        for mSpec in mechanisms:
10874            setState = None
10875            if isinstance(mSpec, base.MechanismName):
10876                result.append(now.graph.addMechanism(mSpec, where))
10877            elif (
10878                isinstance(mSpec, tuple)
10879            and len(mSpec) == 2
10880            and isinstance(mSpec[0], base.MechanismName)
10881            and isinstance(mSpec[1], base.MechanismState)
10882            ):
10883                result.append(now.graph.addMechanism(mSpec[0], where))
10884                setState = mSpec[1]
10885            else:
10886                raise TypeError(
10887                    f"Invalid mechanism: {repr(mSpec)} (must be a"
10888                    f" mechanism name or a (name, state) tuple."
10889                )
10890
10891            if setState:
10892                self.setMechanismStateNow(result[-1], setState)
10893
10894        return result
10895
10896    def reZone(
10897        self,
10898        zone: base.Zone,
10899        where: base.AnyDecisionSpecifier,
10900        replace: Union[base.Zone, int] = 0
10901    ) -> None:
10902        """
10903        Alters the current graph without adding a new exploration step.
10904
10905        Calls `DecisionGraph.replaceZonesInHierarchy` targeting the
10906        specified decision. Note that per the logic of that method, ALL
10907        zones at the specified hierarchy level are replaced, even if a
10908        specific zone to replace is specified here.
10909
10910        TODO: not that?
10911
10912        The level value is either specified via `replace` (default 0) or
10913        deduced from the zone provided as the `replace` value using
10914        `DecisionGraph.zoneHierarchyLevel`.
10915        """
10916        now = self.getSituation()
10917
10918        if isinstance(replace, int):
10919            level = replace
10920        else:
10921            level = now.graph.zoneHierarchyLevel(replace)
10922
10923        now.graph.replaceZonesInHierarchy(where, zone, level)
10924
10925    def runCommand(
10926        self,
10927        command: commands.Command,
10928        scope: Optional[commands.Scope] = None,
10929        line: int = -1
10930    ) -> commands.CommandResult:
10931        """
10932        Runs a single `Command` applying effects to the exploration, its
10933        current graph, and the provided execution context, and returning
10934        a command result, which contains the modified scope plus
10935        optional skip and label values (see `CommandResult`). This
10936        function also directly modifies the scope you give it. Variable
10937        references in the command are resolved via entries in the
10938        provided scope. If no scope is given, an empty one is created.
10939
10940        A line number may be supplied for use in error messages; if left
10941        out line -1 will be used.
10942
10943        Raises an error if the command is invalid.
10944
10945        For commands that establish a value as the 'current value', that
10946        value will be stored in the '_' variable. When this happens, the
10947        old contents of '_' are stored in '__' first, and the old
10948        contents of '__' are discarded. Note that non-automatic
10949        assignment to '_' does not move the old value to '__'.
10950        """
10951        try:
10952            if scope is None:
10953                scope = {}
10954
10955            skip: Union[int, str, None] = None
10956            label: Optional[str] = None
10957
10958            if command.command == 'val':
10959                command = cast(commands.LiteralValue, command)
10960                result = commands.resolveValue(command.value, scope)
10961                commands.pushCurrentValue(scope, result)
10962
10963            elif command.command == 'empty':
10964                command = cast(commands.EstablishCollection, command)
10965                collection = commands.resolveVarName(command.collection, scope)
10966                commands.pushCurrentValue(
10967                    scope,
10968                    {
10969                        'list': [],
10970                        'tuple': (),
10971                        'set': set(),
10972                        'dict': {},
10973                    }[collection]
10974                )
10975
10976            elif command.command == 'append':
10977                command = cast(commands.AppendValue, command)
10978                target = scope['_']
10979                addIt = commands.resolveValue(command.value, scope)
10980                if isinstance(target, list):
10981                    target.append(addIt)
10982                elif isinstance(target, tuple):
10983                    scope['_'] = target + (addIt,)
10984                elif isinstance(target, set):
10985                    target.add(addIt)
10986                elif isinstance(target, dict):
10987                    raise TypeError(
10988                        "'append' command cannot be used with a"
10989                        " dictionary. Use 'set' instead."
10990                    )
10991                else:
10992                    raise TypeError(
10993                        f"Invalid current value for 'append' command."
10994                        f" The current value must be a list, tuple, or"
10995                        f" set, but it was a '{type(target).__name__}'."
10996                    )
10997
10998            elif command.command == 'set':
10999                command = cast(commands.SetValue, command)
11000                target = scope['_']
11001                where = commands.resolveValue(command.location, scope)
11002                what = commands.resolveValue(command.value, scope)
11003                if isinstance(target, list):
11004                    if not isinstance(where, int):
11005                        raise TypeError(
11006                            f"Cannot set item in list: index {where!r}"
11007                            f" is not an integer."
11008                        )
11009                    target[where] = what
11010                elif isinstance(target, tuple):
11011                    if not isinstance(where, int):
11012                        raise TypeError(
11013                            f"Cannot set item in tuple: index {where!r}"
11014                            f" is not an integer."
11015                        )
11016                    if not (
11017                        0 <= where < len(target)
11018                    or -1 >= where >= -len(target)
11019                    ):
11020                        raise IndexError(
11021                            f"Cannot set item in tuple at index"
11022                            f" {where}: Tuple has length {len(target)}."
11023                        )
11024                    scope['_'] = target[:where] + (what,) + target[where + 1:]
11025                elif isinstance(target, set):
11026                    if what:
11027                        target.add(where)
11028                    else:
11029                        try:
11030                            target.remove(where)
11031                        except KeyError:
11032                            pass
11033                elif isinstance(target, dict):
11034                    target[where] = what
11035
11036            elif command.command == 'pop':
11037                command = cast(commands.PopValue, command)
11038                target = scope['_']
11039                if isinstance(target, list):
11040                    result = target.pop()
11041                    commands.pushCurrentValue(scope, result)
11042                elif isinstance(target, tuple):
11043                    result = target[-1]
11044                    updated = target[:-1]
11045                    scope['__'] = updated
11046                    scope['_'] = result
11047                else:
11048                    raise TypeError(
11049                        f"Cannot 'pop' from a {type(target).__name__}"
11050                        f" (current value must be a list or tuple)."
11051                    )
11052
11053            elif command.command == 'get':
11054                command = cast(commands.GetValue, command)
11055                target = scope['_']
11056                where = commands.resolveValue(command.location, scope)
11057                if isinstance(target, list):
11058                    if not isinstance(where, int):
11059                        raise TypeError(
11060                            f"Cannot get item from list: index"
11061                            f" {where!r} is not an integer."
11062                        )
11063                elif isinstance(target, tuple):
11064                    if not isinstance(where, int):
11065                        raise TypeError(
11066                            f"Cannot get item from tuple: index"
11067                            f" {where!r} is not an integer."
11068                        )
11069                elif isinstance(target, set):
11070                    result = where in target
11071                    commands.pushCurrentValue(scope, result)
11072                elif isinstance(target, dict):
11073                    result = target[where]
11074                    commands.pushCurrentValue(scope, result)
11075                else:
11076                    result = getattr(target, where)
11077                    commands.pushCurrentValue(scope, result)
11078
11079            elif command.command == 'remove':
11080                command = cast(commands.RemoveValue, command)
11081                target = scope['_']
11082                where = commands.resolveValue(command.location, scope)
11083                if isinstance(target, (list, tuple)):
11084                    # this cast is not correct but suppresses warnings
11085                    # given insufficient narrowing by MyPy
11086                    target = cast(Tuple[Any, ...], target)
11087                    if not isinstance(where, int):
11088                        raise TypeError(
11089                            f"Cannot remove item from list or tuple:"
11090                            f" index {where!r} is not an integer."
11091                        )
11092                    scope['_'] = target[:where] + target[where + 1:]
11093                elif isinstance(target, set):
11094                    target.remove(where)
11095                elif isinstance(target, dict):
11096                    del target[where]
11097                else:
11098                    raise TypeError(
11099                        f"Cannot use 'remove' on a/an"
11100                        f" {type(target).__name__}."
11101                    )
11102
11103            elif command.command == 'op':
11104                command = cast(commands.ApplyOperator, command)
11105                left = commands.resolveValue(command.left, scope)
11106                right = commands.resolveValue(command.right, scope)
11107                op = command.op
11108                if op == '+':
11109                    result = left + right
11110                elif op == '-':
11111                    result = left - right
11112                elif op == '*':
11113                    result = left * right
11114                elif op == '/':
11115                    result = left / right
11116                elif op == '//':
11117                    result = left // right
11118                elif op == '**':
11119                    result = left ** right
11120                elif op == '%':
11121                    result = left % right
11122                elif op == '^':
11123                    result = left ^ right
11124                elif op == '|':
11125                    result = left | right
11126                elif op == '&':
11127                    result = left & right
11128                elif op == 'and':
11129                    result = left and right
11130                elif op == 'or':
11131                    result = left or right
11132                elif op == '<':
11133                    result = left < right
11134                elif op == '>':
11135                    result = left > right
11136                elif op == '<=':
11137                    result = left <= right
11138                elif op == '>=':
11139                    result = left >= right
11140                elif op == '==':
11141                    result = left == right
11142                elif op == 'is':
11143                    result = left is right
11144                else:
11145                    raise RuntimeError("Invalid operator '{op}'.")
11146
11147                commands.pushCurrentValue(scope, result)
11148
11149            elif command.command == 'unary':
11150                command = cast(commands.ApplyUnary, command)
11151                value = commands.resolveValue(command.value, scope)
11152                op = command.op
11153                if op == '-':
11154                    result = -value
11155                elif op == '~':
11156                    result = ~value
11157                elif op == 'not':
11158                    result = not value
11159
11160                commands.pushCurrentValue(scope, result)
11161
11162            elif command.command == 'assign':
11163                command = cast(commands.VariableAssignment, command)
11164                varname = commands.resolveVarName(command.varname, scope)
11165                value = commands.resolveValue(command.value, scope)
11166                scope[varname] = value
11167
11168            elif command.command == 'delete':
11169                command = cast(commands.VariableDeletion, command)
11170                varname = commands.resolveVarName(command.varname, scope)
11171                del scope[varname]
11172
11173            elif command.command == 'load':
11174                command = cast(commands.LoadVariable, command)
11175                varname = commands.resolveVarName(command.varname, scope)
11176                commands.pushCurrentValue(scope, scope[varname])
11177
11178            elif command.command == 'call':
11179                command = cast(commands.FunctionCall, command)
11180                function = command.function
11181                if function.startswith('$'):
11182                    function = commands.resolveValue(function, scope)
11183
11184                toCall: Callable
11185                args: Tuple[str, ...]
11186                kwargs: Dict[str, Any]
11187
11188                if command.target == 'builtin':
11189                    toCall = commands.COMMAND_BUILTINS[function]
11190                    args = (scope['_'],)
11191                    kwargs = {}
11192                    if toCall == round:
11193                        if 'ndigits' in scope:
11194                            kwargs['ndigits'] = scope['ndigits']
11195                    elif toCall == range and args[0] is None:
11196                        start = scope.get('start', 0)
11197                        stop = scope['stop']
11198                        step = scope.get('step', 1)
11199                        args = (start, stop, step)
11200
11201                else:
11202                    if command.target == 'stored':
11203                        toCall = function
11204                    elif command.target == 'graph':
11205                        toCall = getattr(self.getSituation().graph, function)
11206                    elif command.target == 'exploration':
11207                        toCall = getattr(self, function)
11208                    else:
11209                        raise TypeError(
11210                            f"Invalid call target '{command.target}'"
11211                            f" (must be one of 'builtin', 'stored',"
11212                            f" 'graph', or 'exploration'."
11213                        )
11214
11215                    # Fill in arguments via kwargs defined in scope
11216                    args = ()
11217                    kwargs = {}
11218                    signature = inspect.signature(toCall)
11219                    # TODO: Maybe try some type-checking here?
11220                    for argName, param in signature.parameters.items():
11221                        if param.kind == inspect.Parameter.VAR_POSITIONAL:
11222                            if argName in scope:
11223                                args = args + tuple(scope[argName])
11224                            # Else leave args as-is
11225                        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
11226                            # These must have a default
11227                            if argName in scope:
11228                                kwargs[argName] = scope[argName]
11229                        elif param.kind == inspect.Parameter.VAR_KEYWORD:
11230                            # treat as a dictionary
11231                            if argName in scope:
11232                                argsToUse = scope[argName]
11233                                if not isinstance(argsToUse, dict):
11234                                    raise TypeError(
11235                                        f"Variable '{argName}' must"
11236                                        f" hold a dictionary when"
11237                                        f" calling function"
11238                                        f" '{toCall.__name__} which"
11239                                        f" uses that argument as a"
11240                                        f" keyword catchall."
11241                                    )
11242                                kwargs.update(scope[argName])
11243                        else:  # a normal parameter
11244                            if argName in scope:
11245                                args = args + (scope[argName],)
11246                            elif param.default == inspect.Parameter.empty:
11247                                raise TypeError(
11248                                    f"No variable named '{argName}' has"
11249                                    f" been defined to supply the"
11250                                    f" required parameter with that"
11251                                    f" name for function"
11252                                    f" '{toCall.__name__}'."
11253                                )
11254
11255                result = toCall(*args, **kwargs)
11256                commands.pushCurrentValue(scope, result)
11257
11258            elif command.command == 'skip':
11259                command = cast(commands.SkipCommands, command)
11260                doIt = commands.resolveValue(command.condition, scope)
11261                if doIt:
11262                    skip = commands.resolveValue(command.amount, scope)
11263                    if not isinstance(skip, (int, str)):
11264                        raise TypeError(
11265                            f"Skip amount must be an integer or a label"
11266                            f" name (got {skip!r})."
11267                        )
11268
11269            elif command.command == 'label':
11270                command = cast(commands.Label, command)
11271                label = commands.resolveValue(command.name, scope)
11272                if not isinstance(label, str):
11273                    raise TypeError(
11274                        f"Label name must be a string (got {label!r})."
11275                    )
11276
11277            else:
11278                raise ValueError(
11279                    f"Invalid command type: {command.command!r}"
11280                )
11281        except ValueError as e:
11282            raise commands.CommandValueError(command, line, e)
11283        except TypeError as e:
11284            raise commands.CommandTypeError(command, line, e)
11285        except IndexError as e:
11286            raise commands.CommandIndexError(command, line, e)
11287        except KeyError as e:
11288            raise commands.CommandKeyError(command, line, e)
11289        except Exception as e:
11290            raise commands.CommandOtherError(command, line, e)
11291
11292        return (scope, skip, label)
11293
11294    def runCommandBlock(
11295        self,
11296        block: List[commands.Command],
11297        scope: Optional[commands.Scope] = None
11298    ) -> commands.Scope:
11299        """
11300        Runs a list of commands, using the given scope (or creating a new
11301        empty scope if none was provided). Returns the scope after
11302        running all of the commands, which may also edit the exploration
11303        and/or the current graph of course.
11304
11305        Note that if a skip command would skip past the end of the
11306        block, execution will end. If a skip command would skip before
11307        the beginning of the block, execution will start from the first
11308        command.
11309
11310        Example:
11311
11312        >>> e = DiscreteExploration()
11313        >>> scope = e.runCommandBlock([
11314        ...    commands.command('assign', 'decision', "'START'"),
11315        ...    commands.command('call', 'exploration', 'start'),
11316        ...    commands.command('assign', 'where', '$decision'),
11317        ...    commands.command('assign', 'transition', "'left'"),
11318        ...    commands.command('call', 'exploration', 'observe'),
11319        ...    commands.command('assign', 'transition', "'right'"),
11320        ...    commands.command('call', 'exploration', 'observe'),
11321        ...    commands.command('call', 'graph', 'destinationsFrom'),
11322        ...    commands.command('call', 'builtin', 'print'),
11323        ...    commands.command('assign', 'transition', "'right'"),
11324        ...    commands.command('assign', 'destination', "'EastRoom'"),
11325        ...    commands.command('call', 'exploration', 'explore'),
11326        ... ])
11327        {'left': 1, 'right': 2}
11328        >>> scope['decision']
11329        'START'
11330        >>> scope['where']
11331        'START'
11332        >>> scope['_']  # result of 'explore' call is dest ID
11333        2
11334        >>> scope['transition']
11335        'right'
11336        >>> scope['destination']
11337        'EastRoom'
11338        >>> g = e.getSituation().graph
11339        >>> len(e)
11340        3
11341        >>> len(g)
11342        3
11343        >>> g.namesListing(g)
11344        '  0 (START)\\n  1 (_u.0)\\n  2 (EastRoom)\\n'
11345        """
11346        if scope is None:
11347            scope = {}
11348
11349        labelPositions: Dict[str, List[int]] = {}
11350
11351        # Keep going until we've exhausted the commands list
11352        index = 0
11353        while index < len(block):
11354
11355            # Execute the next command
11356            scope, skip, label = self.runCommand(
11357                block[index],
11358                scope,
11359                index + 1
11360            )
11361
11362            # Increment our index, or apply a skip
11363            if skip is None:
11364                index = index + 1
11365
11366            elif isinstance(skip, int):  # Integer skip value
11367                if skip < 0:
11368                    index += skip
11369                    if index < 0:  # can't skip before the start
11370                        index = 0
11371                else:
11372                    index += skip + 1  # may end loop if we skip too far
11373
11374            else:  # must be a label name
11375                if skip in labelPositions:  # an established label
11376                    # We jump to the last previous index, or if there
11377                    # are none, to the first future index.
11378                    prevIndices = [
11379                        x
11380                        for x in labelPositions[skip]
11381                        if x < index
11382                    ]
11383                    futureIndices = [
11384                        x
11385                        for x in labelPositions[skip]
11386                        if x >= index
11387                    ]
11388                    if len(prevIndices) > 0:
11389                        index = max(prevIndices)
11390                    else:
11391                        index = min(futureIndices)
11392                else:  # must be a forward-reference
11393                    for future in range(index + 1, len(block)):
11394                        inspect = block[future]
11395                        if inspect.command == 'label':
11396                            inspect = cast(commands.Label, inspect)
11397                            if inspect.name == skip:
11398                                index = future
11399                                break
11400                    else:
11401                        raise KeyError(
11402                            f"Skip command indicated a jump to label"
11403                            f" {skip!r} but that label had not already"
11404                            f" been defined and there is no future"
11405                            f" label with that name either (future"
11406                            f" labels based on variables cannot be"
11407                            f" skipped to from above as their names"
11408                            f" are not known yet)."
11409                        )
11410
11411            # If there's a label, record it
11412            if label is not None:
11413                labelPositions.setdefault(label, []).append(index)
11414
11415            # And now the while loop continues, or ends if we're at the
11416            # end of the commands list.
11417
11418        # Return the scope object.
11419        return scope
11420
11421    @staticmethod
11422    def example() -> 'DiscreteExploration':
11423        """
11424        Returns a little example exploration. Has a few decisions
11425        including one that's unexplored, and uses a few steps to explore
11426        them.
11427
11428        >>> e = DiscreteExploration.example()
11429        >>> len(e)
11430        7
11431        >>> def pg(n):
11432        ...     print(e[n].graph.namesListing(e[n].graph))
11433        >>> pg(0)
11434          0 (House)
11435        <BLANKLINE>
11436        >>> pg(1)
11437          0 (House)
11438          1 (_u.0)
11439          2 (_u.1)
11440          3 (_u.2)
11441        <BLANKLINE>
11442        >>> pg(2)
11443          0 (House)
11444          1 (_u.0)
11445          2 (_u.1)
11446          3 (Yard)
11447          4 (_u.3)
11448          5 (_u.4)
11449        <BLANKLINE>
11450        >>> pg(3)
11451          0 (House)
11452          1 (_u.0)
11453          2 (_u.1)
11454          3 (Yard)
11455          4 (_u.3)
11456          5 (_u.4)
11457        <BLANKLINE>
11458        >>> pg(4)
11459          0 (House)
11460          1 (_u.0)
11461          2 (Cellar)
11462          3 (Yard)
11463          5 (_u.4)
11464        <BLANKLINE>
11465        >>> pg(5)
11466          0 (House)
11467          1 (_u.0)
11468          2 (Cellar)
11469          3 (Yard)
11470          5 (_u.4)
11471        <BLANKLINE>
11472        >>> pg(6)
11473          0 (House)
11474          1 (_u.0)
11475          2 (Cellar)
11476          3 (Yard)
11477          5 (Lane)
11478        <BLANKLINE>
11479        """
11480        result = DiscreteExploration()
11481        result.start("House")
11482        result.observeAll("House", "ladder", "stairsDown", "frontDoor")
11483        result.explore("frontDoor", "Yard", "frontDoor")
11484        result.observe("Yard", "cellarDoors")
11485        result.observe("Yard", "frontGate")
11486        result.retrace("frontDoor")
11487        result.explore("stairsDown", "Cellar", "stairsUp")
11488        result.observe("Cellar", "stairsOut")
11489        result.returnTo("stairsOut", "Yard", "cellarDoors")
11490        result.explore("frontGate", "Lane", "redGate")
11491        return result
ENDINGS_DOMAIN = 'endings'

Domain value for endings.

TRIGGERS_DOMAIN = 'triggers'

Domain value for triggers.

LookupResult = ~LookupResult

A type variable for lookup results from the generic DecisionGraph.localLookup function.

LookupLayersList = typing.List[typing.Union[NoneType, int, str]]

A list of layers to look things up in, consisting of None for the starting provided decision set, integers for zone heights, and some custom strings like "fallback" and "all" for fallback sets.

class DecisionInfo(typing.TypedDict):
78class DecisionInfo(TypedDict):
79    """
80    The information stored per-decision in a `DecisionGraph` includes
81    the decision name (since the key is a decision ID), the domain, a
82    tags dictionary, and an annotations list.
83    """
84    name: base.DecisionName
85    domain: base.Domain
86    tags: Dict[base.Tag, base.TagValue]
87    annotations: List[base.Annotation]

The information stored per-decision in a DecisionGraph includes the decision name (since the key is a decision ID), the domain, a tags dictionary, and an annotations list.

name: str
domain: str
tags: Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]
annotations: List[str]
class TransitionProperties(typing.TypedDict):
 94class TransitionProperties(TypedDict, total=False):
 95    """
 96    Represents bundled properties of a transition, including a
 97    requirement, effects, tags, and/or annotations. Does not include the
 98    reciprocal. Has the following slots:
 99
100    - `'requirement'`: The requirement for the transition. This is
101        always a `Requirement`, although it might be `ReqNothing` if
102        nothing special is required.
103    - `'consequence'`: The `Consequence` of the transition.
104    - `'tags'`: Any tags applied to the transition (as a dictionary).
105    - `'annotations'`: A list of annotations applied to the transition.
106    """
107    requirement: base.Requirement
108    consequence: base.Consequence
109    tags: Dict[base.Tag, base.TagValue]
110    annotations: List[base.Annotation]

Represents bundled properties of a transition, including a requirement, effects, tags, and/or annotations. Does not include the reciprocal. Has the following slots:

  • 'requirement': The requirement for the transition. This is always a Requirement, although it might be ReqNothing if nothing special is required.
  • 'consequence': The Consequence of the transition.
  • 'tags': Any tags applied to the transition (as a dictionary).
  • 'annotations': A list of annotations applied to the transition.
tags: Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]
annotations: List[str]
def mergeProperties( a: Optional[TransitionProperties], b: Optional[TransitionProperties]) -> TransitionProperties:
113def mergeProperties(
114    a: Optional[TransitionProperties],
115    b: Optional[TransitionProperties]
116) -> TransitionProperties:
117    """
118    Merges two sets of transition properties, following these rules:
119
120    1. Tags and annotations are combined. Annotations from the
121        second property set are ordered after those from the first.
122    2. If one of the transitions has a `ReqNothing` instance as its
123        requirement, we use the other requirement. If both have
124        complex requirements, we create a new `ReqAll` which
125        combines them as the requirement.
126    3. The consequences are merged by placing all of the consequences of
127        the first transition before those of the second one. This may in
128        some cases change the net outcome of those consequences,
129        because not all transition properties are compatible. (Imagine
130        merging two transitions one of which causes a capability to be
131        gained and the other of which causes a capability to be lost.
132        What should happen?).
133    4. The result will not list a reciprocal.
134
135    If either transition is `None`, then a deep copy of the other is
136    returned. If both are `None`, then an empty transition properties
137    dictionary is returned, with `ReqNothing` as the requirement, no
138    effects, no tags, and no annotations.
139
140    Deep copies of consequences are always made, so that any `Effects`
141    applications which edit effects won't end up with entangled effects.
142    """
143    if a is None:
144        if b is None:
145            return {
146                "requirement": base.ReqNothing(),
147                "consequence": [],
148                "tags": {},
149                "annotations": []
150            }
151        else:
152            return copy.deepcopy(b)
153    elif b is None:
154        return copy.deepcopy(a)
155    # implicitly neither a or b is None below
156
157    result: TransitionProperties = {
158        "requirement": base.ReqNothing(),
159        "consequence": copy.deepcopy(a["consequence"] + b["consequence"]),
160        "tags": a["tags"] | b["tags"],
161        "annotations": a["annotations"] + b["annotations"]
162    }
163
164    if a["requirement"] == base.ReqNothing():
165        result["requirement"] = b["requirement"]
166    elif b["requirement"] == base.ReqNothing():
167        result["requirement"] = a["requirement"]
168    else:
169        result["requirement"] = base.ReqAll(
170            [a["requirement"], b["requirement"]]
171        )
172
173    return result

Merges two sets of transition properties, following these rules:

  1. Tags and annotations are combined. Annotations from the second property set are ordered after those from the first.
  2. If one of the transitions has a ReqNothing instance as its requirement, we use the other requirement. If both have complex requirements, we create a new ReqAll which combines them as the requirement.
  3. The consequences are merged by placing all of the consequences of the first transition before those of the second one. This may in some cases change the net outcome of those consequences, because not all transition properties are compatible. (Imagine merging two transitions one of which causes a capability to be gained and the other of which causes a capability to be lost. What should happen?).
  4. The result will not list a reciprocal.

If either transition is None, then a deep copy of the other is returned. If both are None, then an empty transition properties dictionary is returned, with ReqNothing as the requirement, no effects, no tags, and no annotations.

Deep copies of consequences are always made, so that any Effects applications which edit effects won't end up with entangled effects.

class TransitionBlockedWarning(builtins.Warning):
180class TransitionBlockedWarning(Warning):
181    """
182    An warning type for indicating that a transition which has been
183    requested does not have its requirements satisfied by the current
184    game state.
185    """
186    pass

An warning type for indicating that a transition which has been requested does not have its requirements satisfied by the current game state.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class BadStart(builtins.ValueError):
189class BadStart(ValueError):
190    """
191    An error raised when the start method is used improperly.
192    """
193    pass

An error raised when the start method is used improperly.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class MissingDecisionError(builtins.KeyError):
196class MissingDecisionError(KeyError):
197    """
198    An error raised when attempting to use a decision that does not
199    exist.
200    """
201    pass

An error raised when attempting to use a decision that does not exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class AmbiguousDecisionSpecifierError(builtins.KeyError):
204class AmbiguousDecisionSpecifierError(KeyError):
205    """
206    An error raised when an ambiguous decision specifier is provided.
207    Note that if a decision specifier simply doesn't match anything, you
208    will get a `MissingDecisionError` instead.
209    """
210    pass

An error raised when an ambiguous decision specifier is provided. Note that if a decision specifier simply doesn't match anything, you will get a MissingDecisionError instead.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class AmbiguousTransitionError(builtins.KeyError):
213class AmbiguousTransitionError(KeyError):
214    """
215    An error raised when an ambiguous transition is specified.
216    If a transition specifier simply doesn't match anything, you
217    will get a `MissingTransitionError` instead.
218    """
219    pass

An error raised when an ambiguous transition is specified. If a transition specifier simply doesn't match anything, you will get a MissingTransitionError instead.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class MissingTransitionError(builtins.KeyError):
222class MissingTransitionError(KeyError):
223    """
224    An error raised when attempting to use a transition that does not
225    exist.
226    """
227    pass

An error raised when attempting to use a transition that does not exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class MissingMechanismError(builtins.KeyError):
230class MissingMechanismError(KeyError):
231    """
232    An error raised when attempting to use a mechanism that does not
233    exist.
234    """
235    pass

An error raised when attempting to use a mechanism that does not exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class MissingZoneError(builtins.KeyError):
238class MissingZoneError(KeyError):
239    """
240    An error raised when attempting to use a zone that does not exist.
241    """
242    pass

An error raised when attempting to use a zone that does not exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class InvalidLevelError(builtins.ValueError):
245class InvalidLevelError(ValueError):
246    """
247    An error raised when an operation fails because of an invalid zone
248    level.
249    """
250    pass

An error raised when an operation fails because of an invalid zone level.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class InvalidDestinationError(builtins.ValueError):
253class InvalidDestinationError(ValueError):
254    """
255    An error raised when attempting to perform an operation with a
256    transition but that transition does not lead to a destination that's
257    compatible with the operation.
258    """
259    pass

An error raised when attempting to perform an operation with a transition but that transition does not lead to a destination that's compatible with the operation.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class ExplorationStatusError(builtins.ValueError):
262class ExplorationStatusError(ValueError):
263    """
264    An error raised when attempting to perform an operation that
265    requires a previously-visited destination with a decision that
266    represents a not-yet-visited decision, or vice versa. For
267    `Situation`s, Exploration states 'unknown', 'hypothesized', and
268    'noticed' count as "not-yet-visited" while 'exploring' and 'explored'
269    count as "visited" (see `base.hasBeenVisited`) Meanwhile, in a
270    `DecisionGraph` where exploration statuses are not present, the
271    presence or absence of the 'unconfirmed' tag is used to determine
272    whether something has been confirmed or not.
273    """
274    pass

An error raised when attempting to perform an operation that requires a previously-visited destination with a decision that represents a not-yet-visited decision, or vice versa. For Situations, Exploration states 'unknown', 'hypothesized', and 'noticed' count as "not-yet-visited" while 'exploring' and 'explored' count as "visited" (see base.hasBeenVisited) Meanwhile, in a DecisionGraph where exploration statuses are not present, the presence or absence of the 'unconfirmed' tag is used to determine whether something has been confirmed or not.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
WARN_OF_NAME_COLLISIONS = False

Whether or not to issue warnings when two decision names are the same.

class DecisionCollisionWarning(builtins.Warning):
283class DecisionCollisionWarning(Warning):
284    """
285    A warning raised when attempting to create a new decision using the
286    name of a decision that already exists.
287    """
288    pass

A warning raised when attempting to create a new decision using the name of a decision that already exists.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class TransitionCollisionError(builtins.ValueError):
291class TransitionCollisionError(ValueError):
292    """
293    An error raised when attempting to re-use a transition name for a
294    new transition, or otherwise when a transition name conflicts with
295    an already-established transition.
296    """
297    pass

An error raised when attempting to re-use a transition name for a new transition, or otherwise when a transition name conflicts with an already-established transition.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class MechanismCollisionError(builtins.ValueError):
300class MechanismCollisionError(ValueError):
301    """
302    An error raised when attempting to re-use a mechanism name at the
303    same decision where a mechanism with that name already exists.
304    """
305    pass

An error raised when attempting to re-use a mechanism name at the same decision where a mechanism with that name already exists.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class DomainCollisionError(builtins.KeyError):
308class DomainCollisionError(KeyError):
309    """
310    An error raised when attempting to create a domain with the same
311    name as an existing domain.
312    """
313    pass

An error raised when attempting to create a domain with the same name as an existing domain.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class MissingFocalContextError(builtins.KeyError):
316class MissingFocalContextError(KeyError):
317    """
318    An error raised when attempting to pick out a focal context with a
319    name that doesn't exist.
320    """
321    pass

An error raised when attempting to pick out a focal context with a name that doesn't exist.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class FocalContextCollisionError(builtins.KeyError):
324class FocalContextCollisionError(KeyError):
325    """
326    An error raised when attempting to create a focal context with the
327    same name as an existing focal context.
328    """
329    pass

An error raised when attempting to create a focal context with the same name as an existing focal context.

Inherited Members
builtins.KeyError
KeyError
builtins.BaseException
with_traceback
add_note
args
class InvalidActionError(builtins.TypeError):
332class InvalidActionError(TypeError):
333    """
334    An error raised when attempting to take an exploration action which
335    is not correctly formed.
336    """
337    pass

An error raised when attempting to take an exploration action which is not correctly formed.

Inherited Members
builtins.TypeError
TypeError
builtins.BaseException
with_traceback
add_note
args
class ImpossibleActionError(builtins.ValueError):
340class ImpossibleActionError(ValueError):
341    """
342    An error raised when attempting to take an exploration action which
343    is correctly formed but which specifies an action that doesn't match
344    up with the graph state.
345    """
346    pass

An error raised when attempting to take an exploration action which is correctly formed but which specifies an action that doesn't match up with the graph state.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class DoubleActionError(builtins.ValueError):
349class DoubleActionError(ValueError):
350    """
351    An error raised when attempting to set up an `ExplorationAction`
352    when the current situation already has an action specified.
353    """
354    pass

An error raised when attempting to set up an ExplorationAction when the current situation already has an action specified.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class InactiveDomainWarning(builtins.Warning):
357class InactiveDomainWarning(Warning):
358    """
359    A warning used when an inactive domain is referenced but the
360    operation in progress can still succeed (for example when
361    deactivating an already-inactive domain).
362    """

A warning used when an inactive domain is referenced but the operation in progress can still succeed (for example when deactivating an already-inactive domain).

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class ZoneCollisionError(builtins.ValueError):
365class ZoneCollisionError(ValueError):
366    """
367    An error raised when attempting to re-use a zone name for a new zone,
368    or otherwise when a zone name conflicts with an already-established
369    zone.
370    """
371    pass

An error raised when attempting to re-use a zone name for a new zone, or otherwise when a zone name conflicts with an already-established zone.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class DecisionGraph(exploration.graphs.UniqueExitsGraph[int, str]):
 378class DecisionGraph(
 379    graphs.UniqueExitsGraph[base.DecisionID, base.Transition]
 380):
 381    """
 382    Represents a view of the world as a topological graph at a moment in
 383    time. It derives from `networkx.MultiDiGraph`.
 384
 385    Each node (a `Decision`) represents a place in the world where there
 386    are multiple opportunities for travel/action, or a dead end where
 387    you must turn around and go back; typically this is a single room in
 388    a game, but sometimes one room has multiple decision points. Edges
 389    (`Transition`s) represent choices that can be made to travel to
 390    other decision points (e.g., taking the left door), or when they are
 391    self-edges, they represent actions that can be taken within a
 392    location that affect the world or the game state.
 393
 394    Each `Transition` includes a `Effects` dictionary
 395    indicating the effects that it has. Other effects of the transition
 396    that are not simple enough to be included in this format may be
 397    represented in an `DiscreteExploration` by changing the graph in the
 398    next step to reflect further effects of a transition.
 399
 400    In addition to normal transitions between decisions, a
 401    `DecisionGraph` can represent potential transitions which lead to
 402    unknown destinations. These are represented by adding decisions with
 403    the `'unconfirmed'` tag (whose names where not specified begin with
 404    `'_u.'`) with a separate unconfirmed decision for each transition
 405    (although where it's known that two transitions lead to the same
 406    unconfirmed decision, this can be represented as well).
 407
 408    Both nodes and edges can have `Annotation`s associated with them that
 409    include extra details about the explorer's perception of the
 410    situation. They can also have `Tag`s, which represent specific
 411    categories a transition or decision falls into.
 412
 413    Nodes can also be part of one or more `Zones`, and zones can also be
 414    part of other zones, allowing for a hierarchical description of the
 415    underlying space.
 416
 417    Equivalences can be specified to mark that some combination of
 418    capabilities can stand in for another capability.
 419    """
 420    def __init__(self) -> None:
 421        super().__init__()
 422
 423        self.zones: Dict[base.Zone, base.ZoneInfo] = {}
 424        """
 425        Mapping from zone names to zone info
 426        """
 427
 428        self.unknownCount: int = 0
 429        """
 430        Number of unknown decisions that have been created (not number
 431        of current unknown decisions, which is likely lower)
 432        """
 433
 434        self.equivalences: base.Equivalences = {}
 435        """
 436        See `base.Equivalences`. Determines what capabilities and/or
 437        mechanism states can count as active based on alternate
 438        requirements.
 439        """
 440
 441        self.reversionTypes: Dict[str, Set[str]] = {}
 442        """
 443        This tracks shorthand reversion types. See `base.revertedState`
 444        for how these are applied. Keys are custom names and values are
 445        reversion type strings that `base.revertedState` could access.
 446        """
 447
 448        self.nextID: base.DecisionID = 0
 449        """
 450        The ID to use for the next new decision we create.
 451        """
 452
 453        self.nextMechanismID: base.MechanismID = 0
 454        """
 455        ID for the next mechanism.
 456        """
 457
 458        self.mechanisms: Dict[
 459            base.MechanismID,
 460            Tuple[Optional[base.DecisionID], base.MechanismName]
 461        ] = {}
 462        """
 463        Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`)
 464        pairs. For global mechanisms, the `DecisionID` is None.
 465        """
 466
 467        self.globalMechanisms: Dict[
 468            base.MechanismName,
 469            base.MechanismID
 470        ] = {}
 471        """
 472        Global mechanisms
 473        """
 474
 475        self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {}
 476        """
 477        A cache for name -> ID lookups
 478        """
 479
 480    # Note: not hashable
 481
 482    def __eq__(self, other):
 483        """
 484        Equality checker. `DecisionGraph`s can only be equal to other
 485        `DecisionGraph`s, not to other kinds of things.
 486        """
 487        if not isinstance(other, DecisionGraph):
 488            return False
 489        else:
 490            # Checks nodes, edges, and all attached data
 491            if not super().__eq__(other):
 492                return False
 493
 494            # Check unknown count
 495            if self.unknownCount != other.unknownCount:
 496                return False
 497
 498            # Check zones
 499            if self.zones != other.zones:
 500                return False
 501
 502            # Check equivalences
 503            if self.equivalences != other.equivalences:
 504                return False
 505
 506            # Check reversion types
 507            if self.reversionTypes != other.reversionTypes:
 508                return False
 509
 510            # Check mechanisms
 511            if self.nextMechanismID != other.nextMechanismID:
 512                return False
 513
 514            if self.mechanisms != other.mechanisms:
 515                return False
 516
 517            if self.globalMechanisms != other.globalMechanisms:
 518                return False
 519
 520            # Check names:
 521            if self.nameLookup != other.nameLookup:
 522                return False
 523
 524            return True
 525
 526    def listDifferences(
 527        self,
 528        other: 'DecisionGraph'
 529    ) -> Generator[str, None, None]:
 530        """
 531        Generates strings describing differences between this graph and
 532        another graph. This does NOT perform graph matching, so it will
 533        consider graphs different even if they have identical structures
 534        but use different IDs for the nodes in those structures.
 535        """
 536        if not isinstance(other, DecisionGraph):
 537            yield "other is not a graph"
 538        else:
 539            suppress = False
 540            myNodes = set(self.nodes)
 541            theirNodes = set(other.nodes)
 542            for n in myNodes:
 543                if n not in theirNodes:
 544                    suppress = True
 545                    yield (
 546                        f"other graph missing node {n}"
 547                    )
 548                else:
 549                    if self.nodes[n] != other.nodes[n]:
 550                        suppress = True
 551                        yield (
 552                            f"other graph has differences at node {n}:"
 553                            f"\n  Ours:  {self.nodes[n]}"
 554                            f"\nTheirs:  {other.nodes[n]}"
 555                        )
 556                    myDests = self.destinationsFrom(n)
 557                    theirDests = other.destinationsFrom(n)
 558                    for tr in myDests:
 559                        myTo = myDests[tr]
 560                        if tr not in theirDests:
 561                            suppress = True
 562                            yield (
 563                                f"at {self.identityOf(n)}: other graph"
 564                                f" missing transition {tr!r}"
 565                            )
 566                        else:
 567                            theirTo = theirDests[tr]
 568                            if myTo != theirTo:
 569                                suppress = True
 570                                yield (
 571                                    f"at {self.identityOf(n)}: other"
 572                                    f" graph transition {tr!r} leads to"
 573                                    f" {theirTo} instead of {myTo}"
 574                                )
 575                            else:
 576                                myProps = self.edges[n, myTo, tr]  # type:ignore [index] # noqa
 577                                theirProps = other.edges[n, myTo, tr]  # type:ignore [index] # noqa
 578                                if myProps != theirProps:
 579                                    suppress = True
 580                                    yield (
 581                                        f"at {self.identityOf(n)}: other"
 582                                        f" graph transition {tr!r} has"
 583                                        f" different properties:"
 584                                        f"\n  Ours:  {myProps}"
 585                                        f"\nTheirs:  {theirProps}"
 586                                    )
 587            for extra in theirNodes - myNodes:
 588                suppress = True
 589                yield (
 590                    f"other graph has extra node {extra}"
 591                )
 592
 593            # TODO: Fix networkx stubs!
 594            if self.graph != other.graph:  # type:ignore [attr-defined]
 595                suppress = True
 596                yield (
 597                    " different graph attributes:"  # type:ignore [attr-defined]  # noqa
 598                    f"\n  Ours:  {self.graph}"
 599                    f"\nTheirs:  {other.graph}"
 600                )
 601
 602            # Checks any other graph data we might have missed
 603            if not super().__eq__(other) and not suppress:
 604                for attr in dir(self):
 605                    if attr.startswith('__') and attr.endswith('__'):
 606                        continue
 607                    if not hasattr(other, attr):
 608                        yield f"other graph missing attribute: {attr!r}"
 609                    else:
 610                        myVal = getattr(self, attr)
 611                        theirVal = getattr(other, attr)
 612                        if (
 613                            myVal != theirVal
 614                        and not ((callable(myVal) and callable(theirVal)))
 615                        ):
 616                            yield (
 617                                f"other has different val for {attr!r}:"
 618                                f"\n  Ours:  {myVal}"
 619                                f"\nTheirs:  {theirVal}"
 620                            )
 621                for attr in sorted(set(dir(other)) - set(dir(self))):
 622                    yield f"other has extra attribute: {attr!r}"
 623                yield "graph data is different"
 624                # TODO: More detail here!
 625
 626            # Check unknown count
 627            if self.unknownCount != other.unknownCount:
 628                yield "unknown count is different"
 629
 630            # Check zones
 631            if self.zones != other.zones:
 632                yield "zones are different"
 633
 634            # Check equivalences
 635            if self.equivalences != other.equivalences:
 636                yield "equivalences are different"
 637
 638            # Check reversion types
 639            if self.reversionTypes != other.reversionTypes:
 640                yield "reversionTypes are different"
 641
 642            # Check mechanisms
 643            if self.nextMechanismID != other.nextMechanismID:
 644                yield "nextMechanismID is different"
 645
 646            if self.mechanisms != other.mechanisms:
 647                yield "mechanisms are different"
 648
 649            if self.globalMechanisms != other.globalMechanisms:
 650                yield "global mechanisms are different"
 651
 652            # Check names:
 653            if self.nameLookup != other.nameLookup:
 654                for name in self.nameLookup:
 655                    if name not in other.nameLookup:
 656                        yield (
 657                            f"other graph is missing name lookup entry"
 658                            f" for {name!r}"
 659                        )
 660                    else:
 661                        mine = self.nameLookup[name]
 662                        theirs = other.nameLookup[name]
 663                        if theirs != mine:
 664                            yield (
 665                                f"name lookup for {name!r} is {theirs}"
 666                                f" instead of {mine}"
 667                            )
 668                extras = set(other.nameLookup) - set(self.nameLookup)
 669                if extras:
 670                    yield (
 671                        f"other graph has extra name lookup entries:"
 672                        f" {extras}"
 673                    )
 674
 675    def _assignID(self) -> base.DecisionID:
 676        """
 677        Returns the next `base.DecisionID` to use and increments the
 678        next ID counter.
 679        """
 680        result = self.nextID
 681        self.nextID += 1
 682        return result
 683
 684    def _assignMechanismID(self) -> base.MechanismID:
 685        """
 686        Returns the next `base.MechanismID` to use and increments the
 687        next ID counter.
 688        """
 689        result = self.nextMechanismID
 690        self.nextMechanismID += 1
 691        return result
 692
 693    def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
 694        """
 695        Retrieves the decision info for the specified decision, as a
 696        live editable dictionary.
 697
 698        For example:
 699
 700        >>> g = DecisionGraph()
 701        >>> g.addDecision('A')
 702        0
 703        >>> g.annotateDecision('A', 'note')
 704        >>> g.decisionInfo(0)
 705        {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
 706        """
 707        return cast(DecisionInfo, self.nodes[dID])
 708
 709    def resolveDecision(
 710        self,
 711        spec: base.AnyDecisionSpecifier,
 712        zoneHint: Optional[base.Zone] = None,
 713        domainHint: Optional[base.Domain] = None
 714    ) -> base.DecisionID:
 715        """
 716        Given a decision specifier returns the ID associated with that
 717        decision, or raises an `AmbiguousDecisionSpecifierError` or a
 718        `MissingDecisionError` if the specified decision is either
 719        missing or ambiguous. Cannot handle strings that contain domain
 720        and/or zone parts; use
 721        `parsing.ParseFormat.parseDecisionSpecifier` to turn such
 722        strings into `DecisionSpecifier`s if you need to first.
 723
 724        Examples:
 725
 726        >>> g = DecisionGraph()
 727        >>> g.addDecision('A')
 728        0
 729        >>> g.addDecision('B')
 730        1
 731        >>> g.addDecision('C')
 732        2
 733        >>> g.addDecision('A')
 734        3
 735        >>> g.addDecision('B', 'menu')
 736        4
 737        >>> g.createZone('Z', 0)
 738        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 739 annotations=[])
 740        >>> g.createZone('Z2', 0)
 741        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 742 annotations=[])
 743        >>> g.createZone('Zup', 1)
 744        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 745 annotations=[])
 746        >>> g.addDecisionToZone(0, 'Z')
 747        >>> g.addDecisionToZone(1, 'Z')
 748        >>> g.addDecisionToZone(2, 'Z')
 749        >>> g.addDecisionToZone(3, 'Z2')
 750        >>> g.addZoneToZone('Z', 'Zup')
 751        >>> g.addZoneToZone('Z2', 'Zup')
 752        >>> g.resolveDecision(1)
 753        1
 754        >>> g.resolveDecision('A')
 755        Traceback (most recent call last):
 756        ...
 757        exploration.core.AmbiguousDecisionSpecifierError...
 758        >>> g.resolveDecision('B')
 759        Traceback (most recent call last):
 760        ...
 761        exploration.core.AmbiguousDecisionSpecifierError...
 762        >>> g.resolveDecision('C')
 763        2
 764        >>> g.resolveDecision('A', 'Z')
 765        0
 766        >>> g.resolveDecision('A', zoneHint='Z2')
 767        3
 768        >>> g.resolveDecision('B', domainHint='main')
 769        1
 770        >>> g.resolveDecision('B', None, 'menu')
 771        4
 772        >>> g.resolveDecision('B', zoneHint='Z2')
 773        Traceback (most recent call last):
 774        ...
 775        exploration.core.MissingDecisionError...
 776        >>> g.resolveDecision('A', domainHint='menu')
 777        Traceback (most recent call last):
 778        ...
 779        exploration.core.MissingDecisionError...
 780        >>> g.resolveDecision('A', domainHint='madeup')
 781        Traceback (most recent call last):
 782        ...
 783        exploration.core.MissingDecisionError...
 784        >>> g.resolveDecision('A', zoneHint='madeup')
 785        Traceback (most recent call last):
 786        ...
 787        exploration.core.MissingDecisionError...
 788        """
 789        # Parse it to either an ID or specifier if it's a string:
 790        if isinstance(spec, str):
 791            try:
 792                spec = int(spec)
 793            except ValueError:
 794                pass
 795
 796        # If it's an ID, check for existence:
 797        if isinstance(spec, base.DecisionID):
 798            if spec in self:
 799                return spec
 800            else:
 801                raise MissingDecisionError(
 802                    f"There is no decision with ID {spec!r}."
 803                )
 804        else:
 805            if isinstance(spec, base.DecisionName):
 806                spec = base.DecisionSpecifier(
 807                    domain=None,
 808                    zone=None,
 809                    name=spec
 810                )
 811            elif not isinstance(spec, base.DecisionSpecifier):
 812                raise TypeError(
 813                    f"Specification is not provided as a"
 814                    f" DecisionSpecifier or other valid type. (got type"
 815                    f" {type(spec)})."
 816                )
 817
 818            # Merge domain hints from spec/args
 819            if (
 820                spec.domain is not None
 821            and domainHint is not None
 822            and spec.domain != domainHint
 823            ):
 824                raise ValueError(
 825                    f"Specifier {repr(spec)} includes domain hint"
 826                    f" {repr(spec.domain)} which is incompatible with"
 827                    f" explicit domain hint {repr(domainHint)}."
 828                )
 829            else:
 830                domainHint = spec.domain or domainHint
 831
 832            # Merge zone hints from spec/args
 833            if (
 834                spec.zone is not None
 835            and zoneHint is not None
 836            and spec.zone != zoneHint
 837            ):
 838                raise ValueError(
 839                    f"Specifier {repr(spec)} includes zone hint"
 840                    f" {repr(spec.zone)} which is incompatible with"
 841                    f" explicit zone hint {repr(zoneHint)}."
 842                )
 843            else:
 844                zoneHint = spec.zone or zoneHint
 845
 846            if spec.name not in self.nameLookup:
 847                raise MissingDecisionError(
 848                    f"No decision named {repr(spec.name)}."
 849                )
 850            else:
 851                options = self.nameLookup[spec.name]
 852                if len(options) == 0:
 853                    raise MissingDecisionError(
 854                        f"No decision named {repr(spec.name)}."
 855                    )
 856                filtered = [
 857                    opt
 858                    for opt in options
 859                    if (
 860                        domainHint is None
 861                     or self.domainFor(opt) == domainHint
 862                    ) and (
 863                        zoneHint is None
 864                     or zoneHint in self.zoneAncestors(opt)
 865                    )
 866                ]
 867                if len(filtered) == 1:
 868                    return filtered[0]
 869                else:
 870                    filterDesc = ""
 871                    if domainHint is not None:
 872                        filterDesc += f" in domain {repr(domainHint)}"
 873                    if zoneHint is not None:
 874                        filterDesc += f" in zone {repr(zoneHint)}"
 875                    if len(filtered) == 0:
 876                        raise MissingDecisionError(
 877                            f"No decisions named"
 878                            f" {repr(spec.name)}{filterDesc}."
 879                        )
 880                    else:
 881                        raise AmbiguousDecisionSpecifierError(
 882                            f"There are {len(filtered)} decisions"
 883                            f" named {repr(spec.name)}{filterDesc}."
 884                        )
 885
 886    def getDecision(
 887        self,
 888        decision: base.AnyDecisionSpecifier,
 889        zoneHint: Optional[base.Zone] = None,
 890        domainHint: Optional[base.Domain] = None
 891    ) -> Optional[base.DecisionID]:
 892        """
 893        Works like `resolveDecision` but returns None instead of raising
 894        a `MissingDecisionError` if the specified decision isn't listed.
 895        May still raise an `AmbiguousDecisionSpecifierError`.
 896        """
 897        try:
 898            return self.resolveDecision(
 899                decision,
 900                zoneHint,
 901                domainHint
 902            )
 903        except MissingDecisionError:
 904            return None
 905
 906    def nameFor(
 907        self,
 908        decision: base.AnyDecisionSpecifier
 909    ) -> base.DecisionName:
 910        """
 911        Returns the name of the specified decision. Note that names are
 912        not necessarily unique.
 913
 914        Example:
 915
 916        >>> d = DecisionGraph()
 917        >>> d.addDecision('A')
 918        0
 919        >>> d.addDecision('B')
 920        1
 921        >>> d.addDecision('B')
 922        2
 923        >>> d.nameFor(0)
 924        'A'
 925        >>> d.nameFor(1)
 926        'B'
 927        >>> d.nameFor(2)
 928        'B'
 929        >>> d.nameFor(3)
 930        Traceback (most recent call last):
 931        ...
 932        exploration.core.MissingDecisionError...
 933        """
 934        dID = self.resolveDecision(decision)
 935        return self.nodes[dID]['name']
 936
 937    def shortIdentity(
 938        self,
 939        decision: Optional[base.AnyDecisionSpecifier],
 940        includeZones: bool = True,
 941        alwaysDomain: Optional[bool] = None
 942    ):
 943        """
 944        Returns a string containing the name for the given decision,
 945        prefixed by its level-0 zone(s) and domain. If the value provided
 946        is `None`, it returns the string "(nowhere)".
 947
 948        If `includeZones` is true (the default) then zone information
 949        is included before the decision name.
 950
 951        If `alwaysDomain` is true or false, then the domain information
 952        will always (or never) be included. If it's `None` (the default)
 953        then domain info will only be included for decisions which are
 954        not in the default domain.
 955        """
 956        if decision is None:
 957            return "(nowhere)"
 958        else:
 959            dID = self.resolveDecision(decision)
 960            thisDomain = self.domainFor(dID)
 961            dSpec = ''
 962            zSpec = ''
 963            if (
 964                alwaysDomain is True
 965             or (
 966                    alwaysDomain is None
 967                and thisDomain != base.DEFAULT_DOMAIN
 968                )
 969            ):
 970                dSpec = thisDomain + '//'  # TODO: Don't hardcode this?
 971            if includeZones:
 972                zones = [
 973                    z
 974                    for z in self.zoneParents(dID)
 975                    if self.zones[z].level == 0
 976                ]
 977                if len(zones) == 1:
 978                    zSpec = zones[0] + '::'  # TODO: Don't hardcode this?
 979                elif len(zones) > 1:
 980                    zSpec = '[' + ', '.join(sorted(zones)) + ']::'
 981                # else leave zSpec empty
 982
 983            return f"{dSpec}{zSpec}{self.nameFor(dID)}"
 984
 985    def identityOf(
 986        self,
 987        decision: Optional[base.AnyDecisionSpecifier],
 988        includeZones: bool = True,
 989        alwaysDomain: Optional[bool] = None
 990    ) -> str:
 991        """
 992        Returns the given node's ID, plus its `shortIdentity` in
 993        parentheses. Arguments are passed through to `shortIdentity`.
 994        """
 995        if decision is None:
 996            return "(nowhere)"
 997        else:
 998            dID = self.resolveDecision(decision)
 999            short = self.shortIdentity(decision, includeZones, alwaysDomain)
1000            return f"{dID} ({short})"
1001
1002    def namesListing(
1003        self,
1004        decisions: Collection[base.DecisionID],
1005        includeZones: bool = True,
1006        indent: int = 2
1007    ) -> str:
1008        """
1009        Returns a multi-line string containing an indented listing of
1010        the provided decision IDs with their names in parentheses after
1011        each. Useful for debugging & error messages.
1012
1013        Includes level-0 zones where applicable, with a zone separator
1014        before the decision, unless `includeZones` is set to False. Where
1015        there are multiple level-0 zones, they're listed together in
1016        brackets.
1017
1018        Uses the string '(none)' when there are no decisions are in the
1019        list.
1020
1021        Set `indent` to something other than 2 to control how much
1022        indentation is added.
1023
1024        For example:
1025
1026        >>> g = DecisionGraph()
1027        >>> g.addDecision('A')
1028        0
1029        >>> g.addDecision('B')
1030        1
1031        >>> g.addDecision('C')
1032        2
1033        >>> g.namesListing(['A', 'C', 'B'])
1034        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
1035        >>> g.namesListing([])
1036        '  (none)\\n'
1037        >>> g.createZone('zone', 0)
1038        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1039 annotations=[])
1040        >>> g.createZone('zone2', 0)
1041        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1042 annotations=[])
1043        >>> g.createZone('zoneUp', 1)
1044        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
1045 annotations=[])
1046        >>> g.addDecisionToZone(0, 'zone')
1047        >>> g.addDecisionToZone(1, 'zone')
1048        >>> g.addDecisionToZone(1, 'zone2')
1049        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
1050        >>> g.namesListing(['A', 'C', 'B'])
1051        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
1052        """
1053        ind = ' ' * indent
1054        if len(decisions) == 0:
1055            return ind + '(none)\n'
1056        else:
1057            result = ''
1058            for dID in decisions:
1059                result += ind + self.identityOf(dID, includeZones) + '\n'
1060            return result
1061
1062    def destinationsListing(
1063        self,
1064        destinations: Dict[base.Transition, base.DecisionID],
1065        includeZones: bool = True,
1066        indent: int = 2
1067    ) -> str:
1068        """
1069        Returns a multi-line string containing an indented listing of
1070        the provided transitions along with their destinations and the
1071        names of those destinations in parentheses. Useful for debugging
1072        & error messages. (Use e.g., `destinationsFrom` to get a
1073        transitions -> destinations dictionary in the required format.)
1074
1075        Uses the string '(no transitions)' when there are no transitions
1076        in the dictionary.
1077
1078        Set `indent` to something other than 2 to control how much
1079        indentation is added.
1080
1081        For example:
1082
1083        >>> g = DecisionGraph()
1084        >>> g.addDecision('A')
1085        0
1086        >>> g.addDecision('B')
1087        1
1088        >>> g.addDecision('C')
1089        2
1090        >>> g.addTransition('A', 'north', 'B', 'south')
1091        >>> g.addTransition('B', 'east', 'C', 'west')
1092        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
1093        >>> g.destinationsListing(g.destinationsFrom('A'))
1094        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
1095        >>> g.destinationsListing(g.destinationsFrom('B'))
1096        '  south to 0 (A)\\n  east to 2 (C)\\n'
1097        >>> g.destinationsListing({})
1098        '  (none)\\n'
1099        >>> g.createZone('zone', 0)
1100        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1101 annotations=[])
1102        >>> g.addDecisionToZone(0, 'zone')
1103        >>> g.destinationsListing(g.destinationsFrom('B'))
1104        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
1105        """
1106        ind = ' ' * indent
1107        if len(destinations) == 0:
1108            return ind + '(none)\n'
1109        else:
1110            result = ''
1111            for transition, dID in destinations.items():
1112                line = f"{transition} to {self.identityOf(dID, includeZones)}"
1113                result += ind + line + '\n'
1114            return result
1115
1116    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
1117        """
1118        Returns the domain that a decision belongs to.
1119        """
1120        dID = self.resolveDecision(decision)
1121        return self.nodes[dID]['domain']
1122
1123    def allDecisionsInDomain(
1124        self,
1125        domain: base.Domain
1126    ) -> Set[base.DecisionID]:
1127        """
1128        Returns the set of all `DecisionID`s for decisions in the
1129        specified domain.
1130        """
1131        return set(dID for dID in self if self.nodes[dID]['domain'] == domain)
1132
1133    def destination(
1134        self,
1135        decision: base.AnyDecisionSpecifier,
1136        transition: base.Transition
1137    ) -> base.DecisionID:
1138        """
1139        Overrides base `UniqueExitsGraph.destination` to raise
1140        `MissingDecisionError` or `MissingTransitionError` as
1141        appropriate, and to work with an `AnyDecisionSpecifier`.
1142        """
1143        dID = self.resolveDecision(decision)
1144        try:
1145            return super().destination(dID, transition)
1146        except KeyError:
1147            raise MissingTransitionError(
1148                f"Transition {transition!r} does not exist at decision"
1149                f" {self.identityOf(dID)}."
1150            )
1151
1152    def getDestination(
1153        self,
1154        decision: base.AnyDecisionSpecifier,
1155        transition: base.Transition,
1156        default: Any = None
1157    ) -> Optional[base.DecisionID]:
1158        """
1159        Overrides base `UniqueExitsGraph.getDestination` with different
1160        argument names, since those matter for the edit DSL.
1161        """
1162        dID = self.resolveDecision(decision)
1163        return super().getDestination(dID, transition)
1164
1165    def destinationsFrom(
1166        self,
1167        decision: base.AnyDecisionSpecifier
1168    ) -> Dict[base.Transition, base.DecisionID]:
1169        """
1170        Override that just changes the type of the exception from a
1171        `KeyError` to a `MissingDecisionError` when the source does not
1172        exist.
1173        """
1174        dID = self.resolveDecision(decision)
1175        return super().destinationsFrom(dID)
1176
1177    def bothEnds(
1178        self,
1179        decision: base.AnyDecisionSpecifier,
1180        transition: base.Transition
1181    ) -> Set[base.DecisionID]:
1182        """
1183        Returns a set containing the `DecisionID`(s) for both the start
1184        and end of the specified transition. Raises a
1185        `MissingDecisionError` or `MissingTransitionError`if the
1186        specified decision and/or transition do not exist.
1187
1188        Note that for actions since the source and destination are the
1189        same, the set will have only one element.
1190        """
1191        dID = self.resolveDecision(decision)
1192        result = {dID}
1193        dest = self.destination(dID, transition)
1194        if dest is not None:
1195            result.add(dest)
1196        return result
1197
1198    def decisionActions(
1199        self,
1200        decision: base.AnyDecisionSpecifier
1201    ) -> Set[base.Transition]:
1202        """
1203        Retrieves the set of self-edges at a decision. Editing the set
1204        will not affect the graph.
1205
1206        Example:
1207
1208        >>> g = DecisionGraph()
1209        >>> g.addDecision('A')
1210        0
1211        >>> g.addDecision('B')
1212        1
1213        >>> g.addDecision('C')
1214        2
1215        >>> g.addAction('A', 'action1')
1216        >>> g.addAction('A', 'action2')
1217        >>> g.addAction('B', 'action3')
1218        >>> sorted(g.decisionActions('A'))
1219        ['action1', 'action2']
1220        >>> g.decisionActions('B')
1221        {'action3'}
1222        >>> g.decisionActions('C')
1223        set()
1224        """
1225        result = set()
1226        dID = self.resolveDecision(decision)
1227        for transition, dest in self.destinationsFrom(dID).items():
1228            if dest == dID:
1229                result.add(transition)
1230        return result
1231
1232    def getTransitionProperties(
1233        self,
1234        decision: base.AnyDecisionSpecifier,
1235        transition: base.Transition
1236    ) -> TransitionProperties:
1237        """
1238        Returns a dictionary containing transition properties for the
1239        specified transition from the specified decision. The properties
1240        included are:
1241
1242        - 'requirement': The requirement for the transition.
1243        - 'consequence': Any consequence of the transition.
1244        - 'tags': Any tags applied to the transition.
1245        - 'annotations': Any annotations on the transition.
1246
1247        The reciprocal of the transition is not included.
1248
1249        The result is a clone of the stored properties; edits to the
1250        dictionary will NOT modify the graph.
1251        """
1252        dID = self.resolveDecision(decision)
1253        dest = self.destination(dID, transition)
1254
1255        info: TransitionProperties = copy.deepcopy(
1256            self.edges[dID, dest, transition]  # type:ignore
1257        )
1258        return {
1259            'requirement': info.get('requirement', base.ReqNothing()),
1260            'consequence': info.get('consequence', []),
1261            'tags': info.get('tags', {}),
1262            'annotations': info.get('annotations', [])
1263        }
1264
1265    def setTransitionProperties(
1266        self,
1267        decision: base.AnyDecisionSpecifier,
1268        transition: base.Transition,
1269        requirement: Optional[base.Requirement] = None,
1270        consequence: Optional[base.Consequence] = None,
1271        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1272        annotations: Optional[List[base.Annotation]] = None
1273    ) -> None:
1274        """
1275        Sets one or more transition properties all at once. Can be used
1276        to set the requirement, consequence, tags, and/or annotations.
1277        Old values are overwritten, although if `None`s are provided (or
1278        arguments are omitted), corresponding properties are not
1279        updated.
1280
1281        To add tags or annotations to existing tags/annotations instead
1282        of replacing them, use `tagTransition` or `annotateTransition`
1283        instead.
1284        """
1285        dID = self.resolveDecision(decision)
1286        if requirement is not None:
1287            self.setTransitionRequirement(dID, transition, requirement)
1288        if consequence is not None:
1289            self.setConsequence(dID, transition, consequence)
1290        if tags is not None:
1291            dest = self.destination(dID, transition)
1292            # TODO: Submit pull request to update MultiDiGraph stubs in
1293            # types-networkx to include OutMultiEdgeView that accepts
1294            # from/to/key tuples as indices.
1295            info = cast(
1296                TransitionProperties,
1297                self.edges[dID, dest, transition]  # type:ignore
1298            )
1299            info['tags'] = tags
1300        if annotations is not None:
1301            dest = self.destination(dID, transition)
1302            info = cast(
1303                TransitionProperties,
1304                self.edges[dID, dest, transition]  # type:ignore
1305            )
1306            info['annotations'] = annotations
1307
1308    def getTransitionRequirement(
1309        self,
1310        decision: base.AnyDecisionSpecifier,
1311        transition: base.Transition
1312    ) -> base.Requirement:
1313        """
1314        Returns the `Requirement` for accessing a specific transition at
1315        a specific decision. For transitions which don't have
1316        requirements, returns a `ReqNothing` instance.
1317        """
1318        dID = self.resolveDecision(decision)
1319        dest = self.destination(dID, transition)
1320
1321        info = cast(
1322            TransitionProperties,
1323            self.edges[dID, dest, transition]  # type:ignore
1324        )
1325
1326        return info.get('requirement', base.ReqNothing())
1327
1328    def setTransitionRequirement(
1329        self,
1330        decision: base.AnyDecisionSpecifier,
1331        transition: base.Transition,
1332        requirement: Optional[base.Requirement]
1333    ) -> None:
1334        """
1335        Sets the `Requirement` for accessing a specific transition at
1336        a specific decision. Raises a `KeyError` if the decision or
1337        transition does not exist.
1338
1339        Deletes the requirement if `None` is given as the requirement.
1340
1341        Use `parsing.ParseFormat.parseRequirement` first if you have a
1342        requirement in string format.
1343
1344        Does not raise an error if deletion is requested for a
1345        non-existent requirement, and silently overwrites any previous
1346        requirement.
1347        """
1348        dID = self.resolveDecision(decision)
1349
1350        dest = self.destination(dID, transition)
1351
1352        info = cast(
1353            TransitionProperties,
1354            self.edges[dID, dest, transition]  # type:ignore
1355        )
1356
1357        if requirement is None:
1358            try:
1359                del info['requirement']
1360            except KeyError:
1361                pass
1362        else:
1363            if not isinstance(requirement, base.Requirement):
1364                raise TypeError(
1365                    f"Invalid requirement type: {type(requirement)}"
1366                )
1367
1368            info['requirement'] = requirement
1369
1370    def getConsequence(
1371        self,
1372        decision: base.AnyDecisionSpecifier,
1373        transition: base.Transition
1374    ) -> base.Consequence:
1375        """
1376        Retrieves the consequence of a transition.
1377
1378        A `KeyError` is raised if the specified decision/transition
1379        combination doesn't exist.
1380        """
1381        dID = self.resolveDecision(decision)
1382
1383        dest = self.destination(dID, transition)
1384
1385        info = cast(
1386            TransitionProperties,
1387            self.edges[dID, dest, transition]  # type:ignore
1388        )
1389
1390        return info.get('consequence', [])
1391
1392    def addConsequence(
1393        self,
1394        decision: base.AnyDecisionSpecifier,
1395        transition: base.Transition,
1396        consequence: base.Consequence
1397    ) -> Tuple[int, int]:
1398        """
1399        Adds the given `Consequence` to the consequence list for the
1400        specified transition, extending that list at the end. Note that
1401        this does NOT make a copy of the consequence, so it should not
1402        be used to copy consequences from one transition to another
1403        without making a deep copy first.
1404
1405        A `MissingDecisionError` or a `MissingTransitionError` is raised
1406        if the specified decision/transition combination doesn't exist.
1407
1408        Returns a pair of integers indicating the minimum and maximum
1409        depth-first-traversal-indices of the added consequence part(s).
1410        The outer consequence list itself (index 0) is not counted.
1411
1412        >>> d = DecisionGraph()
1413        >>> d.addDecision('A')
1414        0
1415        >>> d.addDecision('B')
1416        1
1417        >>> d.addTransition('A', 'fwd', 'B', 'rev')
1418        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
1419        (1, 1)
1420        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
1421        (1, 1)
1422        >>> ef = d.getConsequence('A', 'fwd')
1423        >>> er = d.getConsequence('B', 'rev')
1424        >>> ef == [base.effect(gain='sword')]
1425        True
1426        >>> er == [base.effect(lose='sword')]
1427        True
1428        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
1429        (2, 2)
1430        >>> ef = d.getConsequence('A', 'fwd')
1431        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
1432        True
1433        >>> d.addConsequence(
1434        ...     'A',
1435        ...     'fwd',  # adding to consequence with 3 parts already
1436        ...     [  # outer list not counted because it merges
1437        ...         base.challenge(  # 1 part
1438        ...             None,
1439        ...             0,
1440        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
1441        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
1442        ...         )
1443        ...     ]
1444        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
1445        (3, 7)
1446        """
1447        dID = self.resolveDecision(decision)
1448
1449        dest = self.destination(dID, transition)
1450
1451        info = cast(
1452            TransitionProperties,
1453            self.edges[dID, dest, transition]  # type:ignore
1454        )
1455
1456        existing = info.setdefault('consequence', [])
1457        startIndex = base.countParts(existing)
1458        existing.extend(consequence)
1459        endIndex = base.countParts(existing) - 1
1460        return (startIndex, endIndex)
1461
1462    def setConsequence(
1463        self,
1464        decision: base.AnyDecisionSpecifier,
1465        transition: base.Transition,
1466        consequence: base.Consequence
1467    ) -> None:
1468        """
1469        Replaces the transition consequence for the given transition at
1470        the given decision. Any previous consequence is discarded. See
1471        `Consequence` for the structure of these. Note that this does
1472        NOT make a copy of the consequence, do that first to avoid
1473        effect-entanglement if you're copying a consequence.
1474
1475        A `MissingDecisionError` or a `MissingTransitionError` is raised
1476        if the specified decision/transition combination doesn't exist.
1477        """
1478        dID = self.resolveDecision(decision)
1479
1480        dest = self.destination(dID, transition)
1481
1482        info = cast(
1483            TransitionProperties,
1484            self.edges[dID, dest, transition]  # type:ignore
1485        )
1486
1487        info['consequence'] = consequence
1488
1489    def addEquivalence(
1490        self,
1491        requirement: base.Requirement,
1492        capabilityOrMechanismState: Union[
1493            base.Capability,
1494            Tuple[base.MechanismID, base.MechanismState]
1495        ]
1496    ) -> None:
1497        """
1498        Adds the given requirement as an equivalence for the given
1499        capability or the given mechanism state. Note that having a
1500        capability via an equivalence does not count as actually having
1501        that capability; it only counts for the purpose of satisfying
1502        `Requirement`s.
1503
1504        Note also that because a mechanism-based requirement looks up
1505        the specific mechanism locally based on a name, an equivalence
1506        defined in one location may affect mechanism requirements in
1507        other locations unless the mechanism name in the requirement is
1508        zone-qualified to be specific. But in such situations the base
1509        mechanism would have caused issues in any case.
1510        """
1511        self.equivalences.setdefault(
1512            capabilityOrMechanismState,
1513            set()
1514        ).add(requirement)
1515
1516    def removeEquivalence(
1517        self,
1518        requirement: base.Requirement,
1519        capabilityOrMechanismState: Union[
1520            base.Capability,
1521            Tuple[base.MechanismID, base.MechanismState]
1522        ]
1523    ) -> None:
1524        """
1525        Removes an equivalence. Raises a `KeyError` if no such
1526        equivalence existed.
1527        """
1528        self.equivalences[capabilityOrMechanismState].remove(requirement)
1529
1530    def hasAnyEquivalents(
1531        self,
1532        capabilityOrMechanismState: Union[
1533            base.Capability,
1534            Tuple[base.MechanismID, base.MechanismState]
1535        ]
1536    ) -> bool:
1537        """
1538        Returns `True` if the given capability or mechanism state has at
1539        least one equivalence.
1540        """
1541        return capabilityOrMechanismState in self.equivalences
1542
1543    def allEquivalents(
1544        self,
1545        capabilityOrMechanismState: Union[
1546            base.Capability,
1547            Tuple[base.MechanismID, base.MechanismState]
1548        ]
1549    ) -> Set[base.Requirement]:
1550        """
1551        Returns the set of equivalences for the given capability. This is
1552        a live set which may be modified (it's probably better to use
1553        `addEquivalence` and `removeEquivalence` instead...).
1554        """
1555        return self.equivalences.setdefault(
1556            capabilityOrMechanismState,
1557            set()
1558        )
1559
1560    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
1561        """
1562        Specifies a new reversion type, so that when used in a reversion
1563        aspects set with a colon before the name, all items in the
1564        `equivalentTo` value will be added to that set. These may
1565        include other custom reversion type names (with the colon) but
1566        take care not to create an equivalence loop which would result
1567        in a crash.
1568
1569        If you re-use the same name, it will override the old equivalence
1570        for that name.
1571        """
1572        self.reversionTypes[name] = equivalentTo
1573
1574    def addAction(
1575        self,
1576        decision: base.AnyDecisionSpecifier,
1577        action: base.Transition,
1578        requires: Optional[base.Requirement] = None,
1579        consequence: Optional[base.Consequence] = None,
1580        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1581        annotations: Optional[List[base.Annotation]] = None,
1582    ) -> None:
1583        """
1584        Adds the given action as a possibility at the given decision. An
1585        action is just a self-edge, which can have requirements like any
1586        edge, and which can have consequences like any edge.
1587        The optional arguments are given to `setTransitionRequirement`
1588        and `setConsequence`; see those functions for descriptions
1589        of what they mean.
1590
1591        Raises a `KeyError` if a transition with the given name already
1592        exists at the given decision.
1593        """
1594        if tags is None:
1595            tags = {}
1596        if annotations is None:
1597            annotations = []
1598
1599        dID = self.resolveDecision(decision)
1600
1601        self.add_edge(
1602            dID,
1603            dID,
1604            key=action,
1605            tags=tags,
1606            annotations=annotations
1607        )
1608        self.setTransitionRequirement(dID, action, requires)
1609        if consequence is not None:
1610            self.setConsequence(dID, action, consequence)
1611
1612    def tagDecision(
1613        self,
1614        decision: base.AnyDecisionSpecifier,
1615        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1616        tagValue: Union[
1617            base.TagValue,
1618            type[base.NoTagValue]
1619        ] = base.NoTagValue
1620    ) -> None:
1621        """
1622        Adds a tag (or many tags from a dictionary of tags) to a
1623        decision, using `1` as the value if no value is provided. It's
1624        a `ValueError` to provide a value when a dictionary of tags is
1625        provided to set multiple tags at once.
1626
1627        Note that certain tags have special meanings:
1628
1629        - 'unconfirmed' is used for decisions that represent unconfirmed
1630            parts of the graph (this is separate from the 'unknown'
1631            and/or 'hypothesized' exploration statuses, which are only
1632            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
1633            Various methods require this tag and many also add or remove
1634            it.
1635        """
1636        if isinstance(tagOrTags, base.Tag):
1637            if tagValue is base.NoTagValue:
1638                tagValue = 1
1639
1640            # Not sure why this cast is necessary given the `if` above...
1641            tagValue = cast(base.TagValue, tagValue)
1642
1643            tagOrTags = {tagOrTags: tagValue}
1644
1645        elif tagValue is not base.NoTagValue:
1646            raise ValueError(
1647                "Provided a dictionary to update multiple tags, but"
1648                " also a tag value."
1649            )
1650
1651        dID = self.resolveDecision(decision)
1652
1653        tagsAlready = self.nodes[dID].setdefault('tags', {})
1654        tagsAlready.update(tagOrTags)
1655
1656    def untagDecision(
1657        self,
1658        decision: base.AnyDecisionSpecifier,
1659        tag: base.Tag
1660    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1661        """
1662        Removes a tag from a decision. Returns the tag's old value if
1663        the tag was present and got removed, or `NoTagValue` if the tag
1664        wasn't present.
1665        """
1666        dID = self.resolveDecision(decision)
1667
1668        target = self.nodes[dID]['tags']
1669        try:
1670            return target.pop(tag)
1671        except KeyError:
1672            return base.NoTagValue
1673
1674    def decisionTags(
1675        self,
1676        decision: base.AnyDecisionSpecifier
1677    ) -> Dict[base.Tag, base.TagValue]:
1678        """
1679        Returns the dictionary of tags for a decision. Edits to the
1680        returned value will be applied to the graph.
1681        """
1682        dID = self.resolveDecision(decision)
1683
1684        return self.nodes[dID]['tags']
1685
1686    def annotateDecision(
1687        self,
1688        decision: base.AnyDecisionSpecifier,
1689        annotationOrAnnotations: Union[
1690            base.Annotation,
1691            Sequence[base.Annotation]
1692        ]
1693    ) -> None:
1694        """
1695        Adds an annotation to a decision's annotations list.
1696        """
1697        dID = self.resolveDecision(decision)
1698
1699        if isinstance(annotationOrAnnotations, base.Annotation):
1700            annotationOrAnnotations = [annotationOrAnnotations]
1701        self.nodes[dID]['annotations'].extend(annotationOrAnnotations)
1702
1703    def decisionAnnotations(
1704        self,
1705        decision: base.AnyDecisionSpecifier
1706    ) -> List[base.Annotation]:
1707        """
1708        Returns the list of annotations for the specified decision.
1709        Modifying the list affects the graph.
1710        """
1711        dID = self.resolveDecision(decision)
1712
1713        return self.nodes[dID]['annotations']
1714
1715    def tagTransition(
1716        self,
1717        decision: base.AnyDecisionSpecifier,
1718        transition: base.Transition,
1719        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1720        tagValue: Union[
1721            base.TagValue,
1722            type[base.NoTagValue]
1723        ] = base.NoTagValue
1724    ) -> None:
1725        """
1726        Adds a tag (or each tag from a dictionary) to a transition
1727        coming out of a specific decision. `1` will be used as the
1728        default value if a single tag is supplied; supplying a tag value
1729        when providing a dictionary of multiple tags to update is a
1730        `ValueError`.
1731
1732        Note that certain transition tags have special meanings:
1733        - 'trigger' causes any actions (but not normal transitions) that
1734            it applies to to be automatically triggered when
1735            `advanceSituation` is called and the decision they're
1736            attached to is active in the new situation (as long as the
1737            action's requirements are met). This happens once per
1738            situation; use 'wait' steps to re-apply triggers.
1739        """
1740        dID = self.resolveDecision(decision)
1741
1742        dest = self.destination(dID, transition)
1743        if isinstance(tagOrTags, base.Tag):
1744            if tagValue is base.NoTagValue:
1745                tagValue = 1
1746
1747            # Not sure why this is necessary given the `if` above...
1748            tagValue = cast(base.TagValue, tagValue)
1749
1750            tagOrTags = {tagOrTags: tagValue}
1751        elif tagValue is not base.NoTagValue:
1752            raise ValueError(
1753                "Provided a dictionary to update multiple tags, but"
1754                " also a tag value."
1755            )
1756
1757        info = cast(
1758            TransitionProperties,
1759            self.edges[dID, dest, transition]  # type:ignore
1760        )
1761
1762        info.setdefault('tags', {}).update(tagOrTags)
1763
1764    def untagTransition(
1765        self,
1766        decision: base.AnyDecisionSpecifier,
1767        transition: base.Transition,
1768        tagOrTags: Union[base.Tag, Set[base.Tag]]
1769    ) -> None:
1770        """
1771        Removes a tag (or each tag in a set) from a transition coming out
1772        of a specific decision. Raises a `KeyError` if (one of) the
1773        specified tag(s) is not currently applied to the specified
1774        transition.
1775        """
1776        dID = self.resolveDecision(decision)
1777
1778        dest = self.destination(dID, transition)
1779        if isinstance(tagOrTags, base.Tag):
1780            tagOrTags = {tagOrTags}
1781
1782        info = cast(
1783            TransitionProperties,
1784            self.edges[dID, dest, transition]  # type:ignore
1785        )
1786        tagsAlready = info.setdefault('tags', {})
1787
1788        for tag in tagOrTags:
1789            tagsAlready.pop(tag)
1790
1791    def transitionTags(
1792        self,
1793        decision: base.AnyDecisionSpecifier,
1794        transition: base.Transition
1795    ) -> Dict[base.Tag, base.TagValue]:
1796        """
1797        Returns the dictionary of tags for a transition. Edits to the
1798        returned dictionary will be applied to the graph.
1799        """
1800        dID = self.resolveDecision(decision)
1801
1802        dest = self.destination(dID, transition)
1803        info = cast(
1804            TransitionProperties,
1805            self.edges[dID, dest, transition]  # type:ignore
1806        )
1807        return info.setdefault('tags', {})
1808
1809    def annotateTransition(
1810        self,
1811        decision: base.AnyDecisionSpecifier,
1812        transition: base.Transition,
1813        annotations: Union[base.Annotation, Sequence[base.Annotation]]
1814    ) -> None:
1815        """
1816        Adds an annotation (or a sequence of annotations) to a
1817        transition's annotations list.
1818        """
1819        dID = self.resolveDecision(decision)
1820
1821        dest = self.destination(dID, transition)
1822        if isinstance(annotations, base.Annotation):
1823            annotations = [annotations]
1824        info = cast(
1825            TransitionProperties,
1826            self.edges[dID, dest, transition]  # type:ignore
1827        )
1828        info['annotations'].extend(annotations)
1829
1830    def transitionAnnotations(
1831        self,
1832        decision: base.AnyDecisionSpecifier,
1833        transition: base.Transition
1834    ) -> List[base.Annotation]:
1835        """
1836        Returns the annotation list for a specific transition at a
1837        specific decision. Editing the list affects the graph.
1838        """
1839        dID = self.resolveDecision(decision)
1840
1841        dest = self.destination(dID, transition)
1842        info = cast(
1843            TransitionProperties,
1844            self.edges[dID, dest, transition]  # type:ignore
1845        )
1846        return info['annotations']
1847
1848    def annotateZone(
1849        self,
1850        zone: base.Zone,
1851        annotations: Union[base.Annotation, Sequence[base.Annotation]]
1852    ) -> None:
1853        """
1854        Adds an annotation (or many annotations from a sequence) to a
1855        zone.
1856
1857        Raises a `MissingZoneError` if the specified zone does not exist.
1858        """
1859        if zone not in self.zones:
1860            raise MissingZoneError(
1861                f"Can't add annotation(s) to zone {zone!r} because that"
1862                f" zone doesn't exist yet."
1863            )
1864
1865        if isinstance(annotations, base.Annotation):
1866            annotations = [ annotations ]
1867
1868        self.zones[zone].annotations.extend(annotations)
1869
1870    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
1871        """
1872        Returns the list of annotations for the specified zone (empty if
1873        none have been added yet).
1874        """
1875        return self.zones[zone].annotations
1876
1877    def tagZone(
1878        self,
1879        zone: base.Zone,
1880        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1881        tagValue: Union[
1882            base.TagValue,
1883            type[base.NoTagValue]
1884        ] = base.NoTagValue
1885    ) -> None:
1886        """
1887        Adds a tag (or many tags from a dictionary of tags) to a
1888        zone, using `1` as the value if no value is provided. It's
1889        a `ValueError` to provide a value when a dictionary of tags is
1890        provided to set multiple tags at once.
1891
1892        Raises a `MissingZoneError` if the specified zone does not exist.
1893        """
1894        if zone not in self.zones:
1895            raise MissingZoneError(
1896                f"Can't add tag(s) to zone {zone!r} because that zone"
1897                f" doesn't exist yet."
1898            )
1899
1900        if isinstance(tagOrTags, base.Tag):
1901            if tagValue is base.NoTagValue:
1902                tagValue = 1
1903
1904            # Not sure why this cast is necessary given the `if` above...
1905            tagValue = cast(base.TagValue, tagValue)
1906
1907            tagOrTags = {tagOrTags: tagValue}
1908
1909        elif tagValue is not base.NoTagValue:
1910            raise ValueError(
1911                "Provided a dictionary to update multiple tags, but"
1912                " also a tag value."
1913            )
1914
1915        tagsAlready = self.zones[zone].tags
1916        tagsAlready.update(tagOrTags)
1917
1918    def untagZone(
1919        self,
1920        zone: base.Zone,
1921        tag: base.Tag
1922    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1923        """
1924        Removes a tag from a zone. Returns the tag's old value if the
1925        tag was present and got removed, or `NoTagValue` if the tag
1926        wasn't present.
1927
1928        Raises a `MissingZoneError` if the specified zone does not exist.
1929        """
1930        if zone not in self.zones:
1931            raise MissingZoneError(
1932                f"Can't remove tag {tag!r} from zone {zone!r} because"
1933                f" that zone doesn't exist yet."
1934            )
1935        target = self.zones[zone].tags
1936        try:
1937            return target.pop(tag)
1938        except KeyError:
1939            return base.NoTagValue
1940
1941    def zoneTags(
1942        self,
1943        zone: base.Zone
1944    ) -> Dict[base.Tag, base.TagValue]:
1945        """
1946        Returns the dictionary of tags for a zone. Edits to the returned
1947        value will be applied to the graph. Returns an empty tags
1948        dictionary if called on a zone that didn't have any tags
1949        previously, but raises a `MissingZoneError` if attempting to get
1950        tags for a zone which does not exist.
1951
1952        For example:
1953
1954        >>> g = DecisionGraph()
1955        >>> g.addDecision('A')
1956        0
1957        >>> g.addDecision('B')
1958        1
1959        >>> g.createZone('Zone')
1960        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1961 annotations=[])
1962        >>> g.tagZone('Zone', 'color', 'blue')
1963        >>> g.tagZone(
1964        ...     'Zone',
1965        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
1966        ... )
1967        >>> g.untagZone('Zone', 'sound')
1968        'loud'
1969        >>> g.zoneTags('Zone')
1970        {'color': 'red', 'shape': 'square'}
1971        """
1972        if zone in self.zones:
1973            return self.zones[zone].tags
1974        else:
1975            raise MissingZoneError(
1976                f"Tags for zone {zone!r} don't exist because that"
1977                f" zone has not been created yet."
1978            )
1979
1980    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
1981        """
1982        Creates an empty zone with the given name at the given level
1983        (default 0). Raises a `ZoneCollisionError` if that zone name is
1984        already in use (at any level), including if it's in use by a
1985        decision.
1986
1987        Raises an `InvalidLevelError` if the level value is less than 0.
1988
1989        Returns the `ZoneInfo` for the new blank zone.
1990
1991        For example:
1992
1993        >>> d = DecisionGraph()
1994        >>> d.createZone('Z', 0)
1995        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1996 annotations=[])
1997        >>> d.getZoneInfo('Z')
1998        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1999 annotations=[])
2000        >>> d.createZone('Z2', 0)
2001        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2002 annotations=[])
2003        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
2004        Traceback (most recent call last):
2005        ...
2006        exploration.core.InvalidLevelError...
2007        >>> d.createZone('Z2')  # Name Z2 is already in use
2008        Traceback (most recent call last):
2009        ...
2010        exploration.core.ZoneCollisionError...
2011        """
2012        if level < 0:
2013            raise InvalidLevelError(
2014                "Cannot create a zone with a negative level."
2015            )
2016        if zone in self.zones:
2017            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
2018        if zone in self:
2019            raise ZoneCollisionError(
2020                f"A decision named {zone!r} already exists, so a zone"
2021                f" with that name cannot be created."
2022            )
2023        info: base.ZoneInfo = base.ZoneInfo(
2024            level=level,
2025            parents=set(),
2026            contents=set(),
2027            tags={},
2028            annotations=[]
2029        )
2030        self.zones[zone] = info
2031        return info
2032
2033    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
2034        """
2035        Returns the `ZoneInfo` (level, parents, and contents) for the
2036        specified zone, or `None` if that zone does not exist.
2037
2038        For example:
2039
2040        >>> d = DecisionGraph()
2041        >>> d.createZone('Z', 0)
2042        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2043 annotations=[])
2044        >>> d.getZoneInfo('Z')
2045        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2046 annotations=[])
2047        >>> d.createZone('Z2', 0)
2048        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2049 annotations=[])
2050        >>> d.getZoneInfo('Z2')
2051        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2052 annotations=[])
2053        """
2054        return self.zones.get(zone)
2055
2056    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
2057        """
2058        Deletes the specified zone, returning a `ZoneInfo` object with
2059        the information on the level, parents, and contents of that zone.
2060
2061        Raises a `MissingZoneError` if the zone in question does not
2062        exist.
2063
2064        For example:
2065
2066        >>> d = DecisionGraph()
2067        >>> d.createZone('Z', 0)
2068        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2069 annotations=[])
2070        >>> d.getZoneInfo('Z')
2071        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2072 annotations=[])
2073        >>> d.deleteZone('Z')
2074        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2075 annotations=[])
2076        >>> d.getZoneInfo('Z') is None  # no info any more
2077        True
2078        >>> d.deleteZone('Z')  # can't re-delete
2079        Traceback (most recent call last):
2080        ...
2081        exploration.core.MissingZoneError...
2082        """
2083        info = self.getZoneInfo(zone)
2084        if info is None:
2085            raise MissingZoneError(
2086                f"Cannot delete zone {zone!r}: it does not exist."
2087            )
2088        for sub in info.contents:
2089            if 'zones' in self.nodes[sub]:
2090                try:
2091                    self.nodes[sub]['zones'].remove(zone)
2092                except KeyError:
2093                    pass
2094        del self.zones[zone]
2095        return info
2096
2097    def addDecisionToZone(
2098        self,
2099        decision: base.AnyDecisionSpecifier,
2100        zone: base.Zone
2101    ) -> None:
2102        """
2103        Adds a decision directly to a zone. Should normally only be used
2104        with level-0 zones. Raises a `MissingZoneError` if the specified
2105        zone did not already exist.
2106
2107        For example:
2108
2109        >>> d = DecisionGraph()
2110        >>> d.addDecision('A')
2111        0
2112        >>> d.addDecision('B')
2113        1
2114        >>> d.addDecision('C')
2115        2
2116        >>> d.createZone('Z', 0)
2117        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2118 annotations=[])
2119        >>> d.addDecisionToZone('A', 'Z')
2120        >>> d.getZoneInfo('Z')
2121        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
2122 annotations=[])
2123        >>> d.addDecisionToZone('B', 'Z')
2124        >>> d.getZoneInfo('Z')
2125        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2126 annotations=[])
2127        """
2128        dID = self.resolveDecision(decision)
2129
2130        if zone not in self.zones:
2131            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2132
2133        self.zones[zone].contents.add(dID)
2134        self.nodes[dID].setdefault('zones', set()).add(zone)
2135
2136    def removeDecisionFromZone(
2137        self,
2138        decision: base.AnyDecisionSpecifier,
2139        zone: base.Zone
2140    ) -> bool:
2141        """
2142        Removes a decision from a zone if it had been in it, returning
2143        True if that decision had been in that zone, and False if it was
2144        not in that zone, including if that zone didn't exist.
2145
2146        Note that this only removes a decision from direct zone
2147        membership. If the decision is a member of one or more zones
2148        which are (directly or indirectly) sub-zones of the target zone,
2149        the decision will remain in those zones, and will still be
2150        indirectly part of the target zone afterwards.
2151
2152        Examples:
2153
2154        >>> g = DecisionGraph()
2155        >>> g.addDecision('A')
2156        0
2157        >>> g.addDecision('B')
2158        1
2159        >>> g.createZone('level0', 0)
2160        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2161 annotations=[])
2162        >>> g.createZone('level1', 1)
2163        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2164 annotations=[])
2165        >>> g.createZone('level2', 2)
2166        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2167 annotations=[])
2168        >>> g.createZone('level3', 3)
2169        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2170 annotations=[])
2171        >>> g.addDecisionToZone('A', 'level0')
2172        >>> g.addDecisionToZone('B', 'level0')
2173        >>> g.addZoneToZone('level0', 'level1')
2174        >>> g.addZoneToZone('level1', 'level2')
2175        >>> g.addZoneToZone('level2', 'level3')
2176        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2177        >>> g.removeDecisionFromZone('A', 'level1')
2178        False
2179        >>> g.zoneParents(0)
2180        {'level0'}
2181        >>> g.removeDecisionFromZone('A', 'level0')
2182        True
2183        >>> g.zoneParents(0)
2184        set()
2185        >>> g.removeDecisionFromZone('A', 'level0')
2186        False
2187        >>> g.removeDecisionFromZone('B', 'level0')
2188        True
2189        >>> g.zoneParents(1)
2190        {'level2'}
2191        >>> g.removeDecisionFromZone('B', 'level0')
2192        False
2193        >>> g.removeDecisionFromZone('B', 'level2')
2194        True
2195        >>> g.zoneParents(1)
2196        set()
2197        """
2198        dID = self.resolveDecision(decision)
2199
2200        if zone not in self.zones:
2201            return False
2202
2203        info = self.zones[zone]
2204        if dID not in info.contents:
2205            return False
2206        else:
2207            info.contents.remove(dID)
2208            try:
2209                self.nodes[dID]['zones'].remove(zone)
2210            except KeyError:
2211                pass
2212            return True
2213
2214    def addZoneToZone(
2215        self,
2216        addIt: base.Zone,
2217        addTo: base.Zone
2218    ) -> None:
2219        """
2220        Adds a zone to another zone. The `addIt` one must be at a
2221        strictly lower level than the `addTo` zone, or an
2222        `InvalidLevelError` will be raised.
2223
2224        If the zone to be added didn't already exist, it is created at
2225        one level below the target zone. Similarly, if the zone being
2226        added to didn't already exist, it is created at one level above
2227        the target zone. If neither existed, a `MissingZoneError` will
2228        be raised.
2229
2230        For example:
2231
2232        >>> d = DecisionGraph()
2233        >>> d.addDecision('A')
2234        0
2235        >>> d.addDecision('B')
2236        1
2237        >>> d.addDecision('C')
2238        2
2239        >>> d.createZone('Z', 0)
2240        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2241 annotations=[])
2242        >>> d.addDecisionToZone('A', 'Z')
2243        >>> d.addDecisionToZone('B', 'Z')
2244        >>> d.getZoneInfo('Z')
2245        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2246 annotations=[])
2247        >>> d.createZone('Z2', 0)
2248        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2249 annotations=[])
2250        >>> d.addDecisionToZone('B', 'Z2')
2251        >>> d.addDecisionToZone('C', 'Z2')
2252        >>> d.getZoneInfo('Z2')
2253        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2254 annotations=[])
2255        >>> d.createZone('l1Z', 1)
2256        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2257 annotations=[])
2258        >>> d.createZone('l2Z', 2)
2259        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2260 annotations=[])
2261        >>> d.addZoneToZone('Z', 'l1Z')
2262        >>> d.getZoneInfo('Z')
2263        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
2264 annotations=[])
2265        >>> d.getZoneInfo('l1Z')
2266        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2267 annotations=[])
2268        >>> d.addZoneToZone('l1Z', 'l2Z')
2269        >>> d.getZoneInfo('l1Z')
2270        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2271 annotations=[])
2272        >>> d.getZoneInfo('l2Z')
2273        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2274 annotations=[])
2275        >>> d.addZoneToZone('Z2', 'l2Z')
2276        >>> d.getZoneInfo('Z2')
2277        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
2278 annotations=[])
2279        >>> l2i = d.getZoneInfo('l2Z')
2280        >>> l2i.level
2281        2
2282        >>> l2i.parents
2283        set()
2284        >>> sorted(l2i.contents)
2285        ['Z2', 'l1Z']
2286        >>> d.addZoneToZone('NZ', 'NZ2')
2287        Traceback (most recent call last):
2288        ...
2289        exploration.core.MissingZoneError...
2290        >>> d.addZoneToZone('Z', 'l1Z2')
2291        >>> zi = d.getZoneInfo('Z')
2292        >>> zi.level
2293        0
2294        >>> sorted(zi.parents)
2295        ['l1Z', 'l1Z2']
2296        >>> sorted(zi.contents)
2297        [0, 1]
2298        >>> d.getZoneInfo('l1Z2')
2299        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2300 annotations=[])
2301        >>> d.addZoneToZone('NZ', 'l1Z')
2302        >>> d.getZoneInfo('NZ')
2303        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2304 annotations=[])
2305        >>> zi = d.getZoneInfo('l1Z')
2306        >>> zi.level
2307        1
2308        >>> zi.parents
2309        {'l2Z'}
2310        >>> sorted(zi.contents)
2311        ['NZ', 'Z']
2312        """
2313        # Create one or the other (but not both) if they're missing
2314        addInfo = self.getZoneInfo(addIt)
2315        toInfo = self.getZoneInfo(addTo)
2316        if addInfo is None and toInfo is None:
2317            raise MissingZoneError(
2318                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
2319                f" exists already."
2320            )
2321
2322        # Create missing addIt
2323        elif addInfo is None:
2324            toInfo = cast(base.ZoneInfo, toInfo)
2325            newLevel = toInfo.level - 1
2326            if newLevel < 0:
2327                raise InvalidLevelError(
2328                    f"Zone {addTo!r} is at level {toInfo.level} and so"
2329                    f" a new zone cannot be added underneath it."
2330                )
2331            addInfo = self.createZone(addIt, newLevel)
2332
2333        # Create missing addTo
2334        elif toInfo is None:
2335            addInfo = cast(base.ZoneInfo, addInfo)
2336            newLevel = addInfo.level + 1
2337            if newLevel < 0:
2338                raise InvalidLevelError(
2339                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
2340                    f" and so a new zone cannot be added above it."
2341                )
2342            toInfo = self.createZone(addTo, newLevel)
2343
2344        # Now both addInfo and toInfo are defined
2345        if addInfo.level >= toInfo.level:
2346            raise InvalidLevelError(
2347                f"Cannot add zone {addIt!r} at level {addInfo.level}"
2348                f" to zone {addTo!r} at level {toInfo.level}: zones can"
2349                f" only contain zones of lower levels."
2350            )
2351
2352        # Now both addInfo and toInfo are defined
2353        toInfo.contents.add(addIt)
2354        addInfo.parents.add(addTo)
2355
2356    def removeZoneFromZone(
2357        self,
2358        removeIt: base.Zone,
2359        removeFrom: base.Zone
2360    ) -> bool:
2361        """
2362        Removes a zone from a zone if it had been in it, returning True
2363        if that zone had been in that zone, and False if it was not in
2364        that zone, including if either zone did not exist.
2365
2366        For example:
2367
2368        >>> d = DecisionGraph()
2369        >>> d.createZone('Z', 0)
2370        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2371 annotations=[])
2372        >>> d.createZone('Z2', 0)
2373        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2374 annotations=[])
2375        >>> d.createZone('l1Z', 1)
2376        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2377 annotations=[])
2378        >>> d.createZone('l2Z', 2)
2379        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2380 annotations=[])
2381        >>> d.addZoneToZone('Z', 'l1Z')
2382        >>> d.addZoneToZone('l1Z', 'l2Z')
2383        >>> d.getZoneInfo('Z')
2384        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2385 annotations=[])
2386        >>> d.getZoneInfo('l1Z')
2387        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2388 annotations=[])
2389        >>> d.getZoneInfo('l2Z')
2390        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2391 annotations=[])
2392        >>> d.removeZoneFromZone('l1Z', 'l2Z')
2393        True
2394        >>> d.getZoneInfo('l1Z')
2395        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2396 annotations=[])
2397        >>> d.getZoneInfo('l2Z')
2398        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2399 annotations=[])
2400        >>> d.removeZoneFromZone('Z', 'l1Z')
2401        True
2402        >>> d.getZoneInfo('Z')
2403        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2404 annotations=[])
2405        >>> d.getZoneInfo('l1Z')
2406        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2407 annotations=[])
2408        >>> d.removeZoneFromZone('Z', 'l1Z')
2409        False
2410        >>> d.removeZoneFromZone('Z', 'madeup')
2411        False
2412        >>> d.removeZoneFromZone('nope', 'madeup')
2413        False
2414        >>> d.removeZoneFromZone('nope', 'l1Z')
2415        False
2416        """
2417        remInfo = self.getZoneInfo(removeIt)
2418        fromInfo = self.getZoneInfo(removeFrom)
2419
2420        if remInfo is None or fromInfo is None:
2421            return False
2422
2423        if removeIt not in fromInfo.contents:
2424            return False
2425
2426        remInfo.parents.remove(removeFrom)
2427        fromInfo.contents.remove(removeIt)
2428        return True
2429
2430    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2431        """
2432        Returns a set of all decisions included directly in the given
2433        zone, not counting decisions included via intermediate
2434        sub-zones (see `allDecisionsInZone` to include those).
2435
2436        Raises a `MissingZoneError` if the specified zone does not
2437        exist.
2438
2439        The returned set is a copy, not a live editable set.
2440
2441        For example:
2442
2443        >>> d = DecisionGraph()
2444        >>> d.addDecision('A')
2445        0
2446        >>> d.addDecision('B')
2447        1
2448        >>> d.addDecision('C')
2449        2
2450        >>> d.createZone('Z', 0)
2451        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2452 annotations=[])
2453        >>> d.addDecisionToZone('A', 'Z')
2454        >>> d.addDecisionToZone('B', 'Z')
2455        >>> d.getZoneInfo('Z')
2456        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2457 annotations=[])
2458        >>> d.decisionsInZone('Z')
2459        {0, 1}
2460        >>> d.createZone('Z2', 0)
2461        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2462 annotations=[])
2463        >>> d.addDecisionToZone('B', 'Z2')
2464        >>> d.addDecisionToZone('C', 'Z2')
2465        >>> d.getZoneInfo('Z2')
2466        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2467 annotations=[])
2468        >>> d.decisionsInZone('Z')
2469        {0, 1}
2470        >>> d.decisionsInZone('Z2')
2471        {1, 2}
2472        >>> d.createZone('l1Z', 1)
2473        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2474 annotations=[])
2475        >>> d.addZoneToZone('Z', 'l1Z')
2476        >>> d.decisionsInZone('Z')
2477        {0, 1}
2478        >>> d.decisionsInZone('l1Z')
2479        set()
2480        >>> d.decisionsInZone('madeup')
2481        Traceback (most recent call last):
2482        ...
2483        exploration.core.MissingZoneError...
2484        >>> zDec = d.decisionsInZone('Z')
2485        >>> zDec.add(2)  # won't affect the zone
2486        >>> zDec
2487        {0, 1, 2}
2488        >>> d.decisionsInZone('Z')
2489        {0, 1}
2490        """
2491        info = self.getZoneInfo(zone)
2492        if info is None:
2493            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2494
2495        # Everything that's not a zone must be a decision
2496        return {
2497            item
2498            for item in info.contents
2499            if isinstance(item, base.DecisionID)
2500        }
2501
2502    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
2503        """
2504        Returns the set of all immediate sub-zones of the given zone.
2505        Will be an empty set if there are no sub-zones; raises a
2506        `MissingZoneError` if the specified zone does not exit.
2507
2508        The returned set is a copy, not a live editable set.
2509
2510        For example:
2511
2512        >>> d = DecisionGraph()
2513        >>> d.createZone('Z', 0)
2514        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2515 annotations=[])
2516        >>> d.subZones('Z')
2517        set()
2518        >>> d.createZone('l1Z', 1)
2519        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2520 annotations=[])
2521        >>> d.addZoneToZone('Z', 'l1Z')
2522        >>> d.subZones('Z')
2523        set()
2524        >>> d.subZones('l1Z')
2525        {'Z'}
2526        >>> s = d.subZones('l1Z')
2527        >>> s.add('Q')  # doesn't affect the zone
2528        >>> sorted(s)
2529        ['Q', 'Z']
2530        >>> d.subZones('l1Z')
2531        {'Z'}
2532        >>> d.subZones('madeup')
2533        Traceback (most recent call last):
2534        ...
2535        exploration.core.MissingZoneError...
2536        """
2537        info = self.getZoneInfo(zone)
2538        if info is None:
2539            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2540
2541        # Sub-zones will appear in self.zones
2542        return {
2543            item
2544            for item in info.contents
2545            if isinstance(item, base.Zone)
2546        }
2547
2548    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2549        """
2550        Returns a set containing all decisions in the given zone,
2551        including those included via sub-zones.
2552
2553        Raises a `MissingZoneError` if the specified zone does not
2554        exist.`
2555
2556        For example:
2557
2558        >>> d = DecisionGraph()
2559        >>> d.addDecision('A')
2560        0
2561        >>> d.addDecision('B')
2562        1
2563        >>> d.addDecision('C')
2564        2
2565        >>> d.createZone('Z', 0)
2566        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2567 annotations=[])
2568        >>> d.addDecisionToZone('A', 'Z')
2569        >>> d.addDecisionToZone('B', 'Z')
2570        >>> d.getZoneInfo('Z')
2571        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2572 annotations=[])
2573        >>> d.decisionsInZone('Z')
2574        {0, 1}
2575        >>> d.allDecisionsInZone('Z')
2576        {0, 1}
2577        >>> d.createZone('Z2', 0)
2578        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2579 annotations=[])
2580        >>> d.addDecisionToZone('B', 'Z2')
2581        >>> d.addDecisionToZone('C', 'Z2')
2582        >>> d.getZoneInfo('Z2')
2583        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2584 annotations=[])
2585        >>> d.decisionsInZone('Z')
2586        {0, 1}
2587        >>> d.decisionsInZone('Z2')
2588        {1, 2}
2589        >>> d.allDecisionsInZone('Z2')
2590        {1, 2}
2591        >>> d.createZone('l1Z', 1)
2592        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2593 annotations=[])
2594        >>> d.createZone('l2Z', 2)
2595        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2596 annotations=[])
2597        >>> d.addZoneToZone('Z', 'l1Z')
2598        >>> d.addZoneToZone('l1Z', 'l2Z')
2599        >>> d.addZoneToZone('Z2', 'l2Z')
2600        >>> d.decisionsInZone('Z')
2601        {0, 1}
2602        >>> d.decisionsInZone('Z2')
2603        {1, 2}
2604        >>> d.decisionsInZone('l1Z')
2605        set()
2606        >>> d.allDecisionsInZone('l1Z')
2607        {0, 1}
2608        >>> d.allDecisionsInZone('l2Z')
2609        {0, 1, 2}
2610        """
2611        result: Set[base.DecisionID] = set()
2612        info = self.getZoneInfo(zone)
2613        if info is None:
2614            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2615
2616        for item in info.contents:
2617            if isinstance(item, base.Zone):
2618                # This can't be an error because of the condition above
2619                result |= self.allDecisionsInZone(item)
2620            else:  # it's a decision
2621                result.add(item)
2622
2623        return result
2624
2625    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
2626        """
2627        Returns the hierarchy level of the given zone, as stored in its
2628        zone info.
2629
2630        By convention, level-0 zones contain decisions directly, and
2631        higher-level zones contain zones of lower levels. This
2632        convention is not enforced, and there could be exceptions to it.
2633
2634        Raises a `MissingZoneError` if the specified zone does not
2635        exist.
2636
2637        For example:
2638
2639        >>> d = DecisionGraph()
2640        >>> d.createZone('Z', 0)
2641        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2642 annotations=[])
2643        >>> d.createZone('l1Z', 1)
2644        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2645 annotations=[])
2646        >>> d.createZone('l5Z', 5)
2647        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
2648 annotations=[])
2649        >>> d.zoneHierarchyLevel('Z')
2650        0
2651        >>> d.zoneHierarchyLevel('l1Z')
2652        1
2653        >>> d.zoneHierarchyLevel('l5Z')
2654        5
2655        >>> d.zoneHierarchyLevel('madeup')
2656        Traceback (most recent call last):
2657        ...
2658        exploration.core.MissingZoneError...
2659        """
2660        info = self.getZoneInfo(zone)
2661        if info is None:
2662            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
2663
2664        return info.level
2665
2666    def zoneParents(
2667        self,
2668        zoneOrDecision: Union[base.Zone, base.DecisionID]
2669    ) -> Set[base.Zone]:
2670        """
2671        Returns the set of all zones which directly contain the target
2672        zone or decision.
2673
2674        Raises a `MissingDecisionError` if the target is neither a valid
2675        zone nor a valid decision.
2676
2677        Returns a copy, not a live editable set.
2678
2679        Example:
2680
2681        >>> g = DecisionGraph()
2682        >>> g.addDecision('A')
2683        0
2684        >>> g.addDecision('B')
2685        1
2686        >>> g.createZone('level0', 0)
2687        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2688 annotations=[])
2689        >>> g.createZone('level1', 1)
2690        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2691 annotations=[])
2692        >>> g.createZone('level2', 2)
2693        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2694 annotations=[])
2695        >>> g.createZone('level3', 3)
2696        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2697 annotations=[])
2698        >>> g.addDecisionToZone('A', 'level0')
2699        >>> g.addDecisionToZone('B', 'level0')
2700        >>> g.addZoneToZone('level0', 'level1')
2701        >>> g.addZoneToZone('level1', 'level2')
2702        >>> g.addZoneToZone('level2', 'level3')
2703        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2704        >>> sorted(g.zoneParents(0))
2705        ['level0']
2706        >>> sorted(g.zoneParents(1))
2707        ['level0', 'level2']
2708        """
2709        if zoneOrDecision in self.zones:
2710            zoneOrDecision = cast(base.Zone, zoneOrDecision)
2711            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
2712            return copy.copy(info.parents)
2713        elif zoneOrDecision in self:
2714            return self.nodes[zoneOrDecision].get('zones', set())
2715        else:
2716            raise MissingDecisionError(
2717                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
2718                f" valid decision."
2719            )
2720
2721    def zoneAncestors(
2722        self,
2723        zoneOrDecision: Union[base.Zone, base.DecisionID],
2724        exclude: Set[base.Zone] = set(),
2725        atLevel: Optional[int] = None
2726    ) -> Set[base.Zone]:
2727        """
2728        Returns the set of zones which contain the target zone or
2729        decision, either directly or indirectly. The target is not
2730        included in the set.
2731
2732        Any ones listed in the `exclude` set are also excluded, as are
2733        any of their ancestors which are not also ancestors of the
2734        target zone via another path of inclusion.
2735
2736        If `atLevel` is not `None`, then only zones at that hierarchy
2737        level will be included.
2738
2739        Raises a `MissingDecisionError` if the target is nether a valid
2740        zone nor a valid decision.
2741
2742        Example:
2743
2744        >>> g = DecisionGraph()
2745        >>> g.addDecision('A')
2746        0
2747        >>> g.addDecision('B')
2748        1
2749        >>> g.createZone('level0', 0)
2750        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2751 annotations=[])
2752        >>> g.createZone('level1', 1)
2753        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2754 annotations=[])
2755        >>> g.createZone('level2', 2)
2756        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2757 annotations=[])
2758        >>> g.createZone('level3', 3)
2759        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2760 annotations=[])
2761        >>> g.addDecisionToZone('A', 'level0')
2762        >>> g.addDecisionToZone('B', 'level0')
2763        >>> g.addZoneToZone('level0', 'level1')
2764        >>> g.addZoneToZone('level1', 'level2')
2765        >>> g.addZoneToZone('level2', 'level3')
2766        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2767        >>> sorted(g.zoneAncestors(0))
2768        ['level0', 'level1', 'level2', 'level3']
2769        >>> sorted(g.zoneAncestors(1))
2770        ['level0', 'level1', 'level2', 'level3']
2771        >>> sorted(g.zoneParents(0))
2772        ['level0']
2773        >>> sorted(g.zoneParents(1))
2774        ['level0', 'level2']
2775        >>> sorted(g.zoneAncestors(0, atLevel=2))
2776        ['level2']
2777        >>> sorted(g.zoneAncestors(0, exclude={'level2'}))
2778        ['level0', 'level1']
2779        """
2780        # Copy is important here!
2781        result = set(self.zoneParents(zoneOrDecision))
2782        result -= exclude
2783        for parent in copy.copy(result):
2784            # Recursively dig up ancestors, but exclude
2785            # results-so-far to avoid re-enumerating when there are
2786            # multiple braided inclusion paths.
2787            result |= self.zoneAncestors(parent, result | exclude, atLevel)
2788
2789        if atLevel is not None:
2790            return {z for z in result if self.zoneHierarchyLevel(z) == atLevel}
2791        else:
2792            return result
2793
2794    def region(
2795        self,
2796        decision: base.DecisionID,
2797        useLevel: int=1
2798    ) -> Optional[base.Zone]:
2799        """
2800        Returns the 'region' that this decision belongs to. 'Regions'
2801        are level-1 zones, but when a decision is in multiple level-1
2802        zones, its region counts as the smallest of those zones in terms
2803        of total decisions contained, breaking ties by the one with the
2804        alphabetically earlier name.
2805
2806        Always returns a single zone name string, unless the target
2807        decision is not in any level-1 zones, in which case it returns
2808        `None`.
2809
2810        If `useLevel` is specified, then zones of the specified level
2811        will be used instead of level-1 zones.
2812
2813        Example:
2814
2815        >>> g = DecisionGraph()
2816        >>> g.addDecision('A')
2817        0
2818        >>> g.addDecision('B')
2819        1
2820        >>> g.addDecision('C')
2821        2
2822        >>> g.addDecision('D')
2823        3
2824        >>> g.createZone('zoneX', 0)
2825        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2826 annotations=[])
2827        >>> g.createZone('regionA', 1)
2828        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2829 annotations=[])
2830        >>> g.createZone('zoneY', 0)
2831        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2832 annotations=[])
2833        >>> g.createZone('regionB', 1)
2834        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2835 annotations=[])
2836        >>> g.createZone('regionC', 1)
2837        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2838 annotations=[])
2839        >>> g.createZone('quadrant', 2)
2840        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2841 annotations=[])
2842        >>> g.addDecisionToZone('A', 'zoneX')
2843        >>> g.addDecisionToZone('B', 'zoneY')
2844        >>> # C is not in any level-1 zones
2845        >>> g.addDecisionToZone('D', 'zoneX')
2846        >>> g.addDecisionToZone('D', 'zoneY')  # D is in both
2847        >>> g.addZoneToZone('zoneX', 'regionA')
2848        >>> g.addZoneToZone('zoneY', 'regionB')
2849        >>> g.addZoneToZone('zoneX', 'regionC')  # includes both
2850        >>> g.addZoneToZone('zoneY', 'regionC')
2851        >>> g.addZoneToZone('regionA', 'quadrant')
2852        >>> g.addZoneToZone('regionB', 'quadrant')
2853        >>> g.addDecisionToZone('C', 'regionC')  # Direct in level-2
2854        >>> sorted(g.allDecisionsInZone('zoneX'))
2855        [0, 3]
2856        >>> sorted(g.allDecisionsInZone('zoneY'))
2857        [1, 3]
2858        >>> sorted(g.allDecisionsInZone('regionA'))
2859        [0, 3]
2860        >>> sorted(g.allDecisionsInZone('regionB'))
2861        [1, 3]
2862        >>> sorted(g.allDecisionsInZone('regionC'))
2863        [0, 1, 2, 3]
2864        >>> sorted(g.allDecisionsInZone('quadrant'))
2865        [0, 1, 3]
2866        >>> g.region(0)  # for A; region A is smaller than region C
2867        'regionA'
2868        >>> g.region(1)  # for B; region B is also smaller than C
2869        'regionB'
2870        >>> g.region(2)  # for C
2871        'regionC'
2872        >>> g.region(3)  # for D; tie broken alphabetically
2873        'regionA'
2874        >>> g.region(0, useLevel=0)  # for A at level 0
2875        'zoneX'
2876        >>> g.region(1, useLevel=0)  # for B at level 0
2877        'zoneY'
2878        >>> g.region(2, useLevel=0) is None  # for C at level 0 (none)
2879        True
2880        >>> g.region(3, useLevel=0)  # for D at level 0; tie
2881        'zoneX'
2882        >>> g.region(0, useLevel=2) # for A at level 2
2883        'quadrant'
2884        >>> g.region(1, useLevel=2) # for B at level 2
2885        'quadrant'
2886        >>> g.region(2, useLevel=2) is None # for C at level 2 (none)
2887        True
2888        >>> g.region(3, useLevel=2)  # for D at level 2
2889        'quadrant'
2890        """
2891        relevant = self.zoneAncestors(decision, atLevel=useLevel)
2892        if len(relevant) == 0:
2893            return None
2894        elif len(relevant) == 1:
2895            for zone in relevant:
2896                return zone
2897            return None  # not really necessary but keeps mypy happy
2898        else:
2899            # more than one zone ancestor at the relevant hierarchy
2900            # level: need to measure their sizes
2901            minSize = None
2902            candidates = []
2903            for zone in relevant:
2904                size = len(self.allDecisionsInZone(zone))
2905                if minSize is None or size < minSize:
2906                    candidates = [zone]
2907                    minSize = size
2908                elif size == minSize:
2909                    candidates.append(zone)
2910            return min(candidates)
2911
2912    def zoneEdges(self, zone: base.Zone) -> Optional[
2913        Tuple[
2914            Set[Tuple[base.DecisionID, base.Transition]],
2915            Set[Tuple[base.DecisionID, base.Transition]]
2916        ]
2917    ]:
2918        """
2919        Given a zone to look at, finds all of the transitions which go
2920        out of and into that zone, ignoring internal transitions between
2921        decisions in the zone. This includes all decisions in sub-zones.
2922        The return value is a pair of sets for outgoing and then
2923        incoming transitions, where each transition is specified as a
2924        (sourceID, transitionName) pair.
2925
2926        Returns `None` if the target zone isn't yet fully defined.
2927
2928        Note that this takes time proportional to *all* edges plus *all*
2929        nodes in the graph no matter how large or small the zone in
2930        question is.
2931
2932        >>> g = DecisionGraph()
2933        >>> g.addDecision('A')
2934        0
2935        >>> g.addDecision('B')
2936        1
2937        >>> g.addDecision('C')
2938        2
2939        >>> g.addDecision('D')
2940        3
2941        >>> g.addTransition('A', 'up', 'B', 'down')
2942        >>> g.addTransition('B', 'right', 'C', 'left')
2943        >>> g.addTransition('C', 'down', 'D', 'up')
2944        >>> g.addTransition('D', 'left', 'A', 'right')
2945        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
2946        >>> g.createZone('Z', 0)
2947        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2948 annotations=[])
2949        >>> g.createZone('ZZ', 1)
2950        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2951 annotations=[])
2952        >>> g.addZoneToZone('Z', 'ZZ')
2953        >>> g.addDecisionToZone('A', 'Z')
2954        >>> g.addDecisionToZone('B', 'Z')
2955        >>> g.addDecisionToZone('D', 'ZZ')
2956        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
2957        >>> sorted(outgoing)
2958        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
2959        >>> sorted(incoming)
2960        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
2961        >>> outgoing, incoming = g.zoneEdges('ZZ')
2962        >>> sorted(outgoing)
2963        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
2964        >>> sorted(incoming)
2965        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
2966        >>> g.zoneEdges('madeup') is None
2967        True
2968        """
2969        # Find the interior nodes
2970        try:
2971            interior = self.allDecisionsInZone(zone)
2972        except MissingZoneError:
2973            return None
2974
2975        # Set up our result
2976        results: Tuple[
2977            Set[Tuple[base.DecisionID, base.Transition]],
2978            Set[Tuple[base.DecisionID, base.Transition]]
2979        ] = (set(), set())
2980
2981        # Because finding incoming edges requires searching the entire
2982        # graph anyways, it's more efficient to just consider each edge
2983        # once.
2984        for fromDecision in self:
2985            fromThere = self[fromDecision]
2986            for toDecision in fromThere:
2987                for transition in fromThere[toDecision]:
2988                    sourceIn = fromDecision in interior
2989                    destIn = toDecision in interior
2990                    if sourceIn and not destIn:
2991                        results[0].add((fromDecision, transition))
2992                    elif destIn and not sourceIn:
2993                        results[1].add((fromDecision, transition))
2994
2995        return results
2996
2997    def replaceZonesInHierarchy(
2998        self,
2999        target: base.AnyDecisionSpecifier,
3000        zone: base.Zone,
3001        level: int
3002    ) -> None:
3003        """
3004        This method replaces one or more zones which contain the
3005        specified `target` decision with a specific zone, at a specific
3006        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
3007        named zone doesn't yet exist, it will be created.
3008
3009        To do this, it looks at all zones which contain the target
3010        decision directly or indirectly (see `zoneAncestors`) and which
3011        are at the specified level.
3012
3013        - Any direct children of those zones which are ancestors of the
3014            target decision are removed from those zones and placed into
3015            the new zone instead, regardless of their levels. Indirect
3016            children are not affected (except perhaps indirectly via
3017            their parents' ancestors changing).
3018        - The new zone is placed into every direct parent of those
3019            zones, regardless of their levels (those parents are by
3020            definition all ancestors of the target decision).
3021        - If there were no zones at the target level, every zone at the
3022            next level down which is an ancestor of the target decision
3023            (or just that decision if the level is 0) is placed into the
3024            new zone as a direct child (and is removed from any previous
3025            parents it had). In this case, the new zone will also be
3026            added as a sub-zone to every ancestor of the target decision
3027            at the level above the specified level, if there are any.
3028            * In this case, if there are no zones at the level below the
3029                specified level, the highest level of zones smaller than
3030                that is treated as the level below, down to targeting
3031                the decision itself.
3032            * Similarly, if there are no zones at the level above the
3033                specified level but there are zones at a higher level,
3034                the new zone will be added to each of the zones in the
3035                lowest level above the target level that has zones in it.
3036
3037        A `MissingDecisionError` will be raised if the specified
3038        decision is not valid, or if the decision is left as default but
3039        there is no current decision in the exploration.
3040
3041        An `InvalidLevelError` will be raised if the level is less than
3042        zero.
3043
3044        Example:
3045
3046        >>> g = DecisionGraph()
3047        >>> g.addDecision('decision')
3048        0
3049        >>> g.addDecision('alternate')
3050        1
3051        >>> g.createZone('zone0', 0)
3052        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3053 annotations=[])
3054        >>> g.createZone('zone1', 1)
3055        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3056 annotations=[])
3057        >>> g.createZone('zone2.1', 2)
3058        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3059 annotations=[])
3060        >>> g.createZone('zone2.2', 2)
3061        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3062 annotations=[])
3063        >>> g.createZone('zone3', 3)
3064        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3065 annotations=[])
3066        >>> g.addDecisionToZone('decision', 'zone0')
3067        >>> g.addDecisionToZone('alternate', 'zone0')
3068        >>> g.addZoneToZone('zone0', 'zone1')
3069        >>> g.addZoneToZone('zone1', 'zone2.1')
3070        >>> g.addZoneToZone('zone1', 'zone2.2')
3071        >>> g.addZoneToZone('zone2.1', 'zone3')
3072        >>> g.addZoneToZone('zone2.2', 'zone3')
3073        >>> g.zoneHierarchyLevel('zone0')
3074        0
3075        >>> g.zoneHierarchyLevel('zone1')
3076        1
3077        >>> g.zoneHierarchyLevel('zone2.1')
3078        2
3079        >>> g.zoneHierarchyLevel('zone2.2')
3080        2
3081        >>> g.zoneHierarchyLevel('zone3')
3082        3
3083        >>> sorted(g.decisionsInZone('zone0'))
3084        [0, 1]
3085        >>> sorted(g.zoneAncestors('zone0'))
3086        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3087        >>> g.subZones('zone1')
3088        {'zone0'}
3089        >>> g.zoneParents('zone0')
3090        {'zone1'}
3091        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
3092        >>> g.zoneParents('zone0')
3093        {'zone1'}
3094        >>> g.zoneParents('new0')
3095        {'zone1'}
3096        >>> sorted(g.zoneAncestors('zone0'))
3097        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3098        >>> sorted(g.zoneAncestors('new0'))
3099        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3100        >>> g.decisionsInZone('zone0')
3101        {1}
3102        >>> g.decisionsInZone('new0')
3103        {0}
3104        >>> sorted(g.subZones('zone1'))
3105        ['new0', 'zone0']
3106        >>> g.zoneParents('new0')
3107        {'zone1'}
3108        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
3109        >>> sorted(g.zoneAncestors(0))
3110        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
3111        >>> g.subZones('zone1')
3112        {'zone0'}
3113        >>> g.subZones('new1')
3114        {'new0'}
3115        >>> g.zoneParents('new0')
3116        {'new1'}
3117        >>> sorted(g.zoneParents('zone1'))
3118        ['zone2.1', 'zone2.2']
3119        >>> sorted(g.zoneParents('new1'))
3120        ['zone2.1', 'zone2.2']
3121        >>> g.zoneParents('zone2.1')
3122        {'zone3'}
3123        >>> g.zoneParents('zone2.2')
3124        {'zone3'}
3125        >>> sorted(g.subZones('zone2.1'))
3126        ['new1', 'zone1']
3127        >>> sorted(g.subZones('zone2.2'))
3128        ['new1', 'zone1']
3129        >>> sorted(g.allDecisionsInZone('zone2.1'))
3130        [0, 1]
3131        >>> sorted(g.allDecisionsInZone('zone2.2'))
3132        [0, 1]
3133        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
3134        >>> g.zoneParents('zone2.1')
3135        {'zone3'}
3136        >>> g.zoneParents('zone2.2')
3137        {'zone3'}
3138        >>> g.subZones('zone2.1')
3139        {'zone1'}
3140        >>> g.subZones('zone2.2')
3141        {'zone1'}
3142        >>> g.subZones('new2')
3143        {'new1'}
3144        >>> g.zoneParents('new2')
3145        {'zone3'}
3146        >>> g.allDecisionsInZone('zone2.1')
3147        {1}
3148        >>> g.allDecisionsInZone('zone2.2')
3149        {1}
3150        >>> g.allDecisionsInZone('new2')
3151        {0}
3152        >>> sorted(g.subZones('zone3'))
3153        ['new2', 'zone2.1', 'zone2.2']
3154        >>> g.zoneParents('zone3')
3155        set()
3156        >>> sorted(g.allDecisionsInZone('zone3'))
3157        [0, 1]
3158        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
3159        >>> sorted(g.subZones('zone3'))
3160        ['zone2.1', 'zone2.2']
3161        >>> g.subZones('new3')
3162        {'new2'}
3163        >>> g.zoneParents('zone3')
3164        set()
3165        >>> g.zoneParents('new3')
3166        set()
3167        >>> g.allDecisionsInZone('zone3')
3168        {1}
3169        >>> g.allDecisionsInZone('new3')
3170        {0}
3171        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
3172        >>> g.subZones('new4')
3173        {'new3'}
3174        >>> g.zoneHierarchyLevel('new4')
3175        5
3176
3177        Another example of level collapse when trying to replace a zone
3178        at a level above :
3179
3180        >>> g = DecisionGraph()
3181        >>> g.addDecision('A')
3182        0
3183        >>> g.addDecision('B')
3184        1
3185        >>> g.createZone('level0', 0)
3186        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3187 annotations=[])
3188        >>> g.createZone('level1', 1)
3189        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3190 annotations=[])
3191        >>> g.createZone('level2', 2)
3192        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3193 annotations=[])
3194        >>> g.createZone('level3', 3)
3195        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3196 annotations=[])
3197        >>> g.addDecisionToZone('B', 'level0')
3198        >>> g.addZoneToZone('level0', 'level1')
3199        >>> g.addZoneToZone('level1', 'level2')
3200        >>> g.addZoneToZone('level2', 'level3')
3201        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
3202        >>> g.zoneHierarchyLevel('level3')
3203        3
3204        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
3205        >>> g.zoneHierarchyLevel('newFirst')
3206        1
3207        >>> g.decisionsInZone('newFirst')
3208        {0}
3209        >>> g.decisionsInZone('level3')
3210        set()
3211        >>> sorted(g.allDecisionsInZone('level3'))
3212        [0, 1]
3213        >>> g.subZones('newFirst')
3214        set()
3215        >>> sorted(g.subZones('level3'))
3216        ['level2', 'newFirst']
3217        >>> g.zoneParents('newFirst')
3218        {'level3'}
3219        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
3220        >>> g.zoneHierarchyLevel('newSecond')
3221        2
3222        >>> g.decisionsInZone('newSecond')
3223        set()
3224        >>> g.allDecisionsInZone('newSecond')
3225        {0}
3226        >>> g.subZones('newSecond')
3227        {'newFirst'}
3228        >>> g.zoneParents('newSecond')
3229        {'level3'}
3230        >>> g.zoneParents('newFirst')
3231        {'newSecond'}
3232        >>> sorted(g.subZones('level3'))
3233        ['level2', 'newSecond']
3234        """
3235        tID = self.resolveDecision(target)
3236
3237        if level < 0:
3238            raise InvalidLevelError(
3239                f"Target level must be positive (got {level})."
3240            )
3241
3242        info = self.getZoneInfo(zone)
3243        if info is None:
3244            info = self.createZone(zone, level)
3245        elif level != info.level:
3246            raise InvalidLevelError(
3247                f"Target level ({level}) does not match the level of"
3248                f" the target zone ({zone!r} at level {info.level})."
3249            )
3250
3251        # Collect both parents & ancestors
3252        parents = self.zoneParents(tID)
3253        ancestors = set(self.zoneAncestors(tID))
3254
3255        # Map from levels to sets of zones from the ancestors pool
3256        levelMap: Dict[int, Set[base.Zone]] = {}
3257        highest = -1
3258        for ancestor in ancestors:
3259            ancestorLevel = self.zoneHierarchyLevel(ancestor)
3260            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
3261            if ancestorLevel > highest:
3262                highest = ancestorLevel
3263
3264        # Figure out if we have target zones to replace or not
3265        reparentDecision = False
3266        if level in levelMap:
3267            # If there are zones at the target level,
3268            targetZones = levelMap[level]
3269
3270            above = set()
3271            below = set()
3272
3273            for replaced in targetZones:
3274                above |= self.zoneParents(replaced)
3275                below |= self.subZones(replaced)
3276                if replaced in parents:
3277                    reparentDecision = True
3278
3279            # Only ancestors should be reparented
3280            below &= ancestors
3281
3282        else:
3283            # Find levels w/ zones in them above + below
3284            levelBelow = level - 1
3285            levelAbove = level + 1
3286            below = levelMap.get(levelBelow, set())
3287            above = levelMap.get(levelAbove, set())
3288
3289            while len(below) == 0 and levelBelow > 0:
3290                levelBelow -= 1
3291                below = levelMap.get(levelBelow, set())
3292
3293            if len(below) == 0:
3294                reparentDecision = True
3295
3296            while len(above) == 0 and levelAbove < highest:
3297                levelAbove += 1
3298                above = levelMap.get(levelAbove, set())
3299
3300        # Handle re-parenting zones below
3301        for under in below:
3302            for parent in self.zoneParents(under):
3303                if parent in ancestors:
3304                    self.removeZoneFromZone(under, parent)
3305            self.addZoneToZone(under, zone)
3306
3307        # Add this zone to each parent
3308        for parent in above:
3309            self.addZoneToZone(zone, parent)
3310
3311        # Re-parent the decision itself if necessary
3312        if reparentDecision:
3313            # (using set() here to avoid size-change-during-iteration)
3314            for parent in set(parents):
3315                self.removeDecisionFromZone(tID, parent)
3316            self.addDecisionToZone(tID, zone)
3317
3318    def getReciprocal(
3319        self,
3320        decision: base.AnyDecisionSpecifier,
3321        transition: base.Transition
3322    ) -> Optional[base.Transition]:
3323        """
3324        Returns the reciprocal edge for the specified transition from the
3325        specified decision (see `setReciprocal`). Returns
3326        `None` if no reciprocal has been established for that
3327        transition, or if that decision or transition does not exist.
3328        """
3329        dID = self.resolveDecision(decision)
3330
3331        dest = self.getDestination(dID, transition)
3332        if dest is not None:
3333            info = cast(
3334                TransitionProperties,
3335                self.edges[dID, dest, transition]  # type:ignore
3336            )
3337            recip = info.get("reciprocal")
3338            if recip is not None and not isinstance(recip, base.Transition):
3339                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
3340            return recip
3341        else:
3342            return None
3343
3344    def setReciprocal(
3345        self,
3346        decision: base.AnyDecisionSpecifier,
3347        transition: base.Transition,
3348        reciprocal: Optional[base.Transition],
3349        setBoth: bool = True,
3350        cleanup: bool = True
3351    ) -> None:
3352        """
3353        Sets the 'reciprocal' transition for a particular transition from
3354        a particular decision, and removes the reciprocal property from
3355        any old reciprocal transition.
3356
3357        Raises a `MissingDecisionError` or a `MissingTransitionError` if
3358        the specified decision or transition does not exist.
3359
3360        Raises an `InvalidDestinationError` if the reciprocal transition
3361        does not exist, or if it does exist but does not lead back to
3362        the decision the transition came from.
3363
3364        If `setBoth` is True (the default) then the transition which is
3365        being identified as a reciprocal will also have its reciprocal
3366        property set, pointing back to the primary transition being
3367        modified, and any old reciprocal of that transition will have its
3368        reciprocal set to None. If you want to create a situation with
3369        non-exclusive reciprocals, use `setBoth=False`.
3370
3371        If `cleanup` is True (the default) then abandoned reciprocal
3372        transitions (for both edges if `setBoth` was true) have their
3373        reciprocal properties removed. Set `cleanup` to false if you want
3374        to retain them, although this will result in non-exclusive
3375        reciprocal relationships.
3376
3377        If the `reciprocal` value is None, this deletes the reciprocal
3378        value entirely, and if `setBoth` is true, it does this for the
3379        previous reciprocal edge as well. No error is raised in this case
3380        when there was not already a reciprocal to delete.
3381
3382        Note that one should remove a reciprocal relationship before
3383        redirecting either edge of the pair in a way that gives it a new
3384        reciprocal, since otherwise, a later attempt to remove the
3385        reciprocal with `setBoth` set to True (the default) will end up
3386        deleting the reciprocal information from the other edge that was
3387        already modified. There is no way to reliably detect and avoid
3388        this, because two different decisions could (and often do in
3389        practice) have transitions with identical names, meaning that the
3390        reciprocal value will still be the same, but it will indicate a
3391        different edge in virtue of the destination of the edge changing.
3392
3393        ## Example
3394
3395        >>> g = DecisionGraph()
3396        >>> g.addDecision('G')
3397        0
3398        >>> g.addDecision('H')
3399        1
3400        >>> g.addDecision('I')
3401        2
3402        >>> g.addTransition('G', 'up', 'H', 'down')
3403        >>> g.addTransition('G', 'next', 'H', 'prev')
3404        >>> g.addTransition('H', 'next', 'I', 'prev')
3405        >>> g.addTransition('H', 'return', 'G')
3406        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
3407        Traceback (most recent call last):
3408        ...
3409        exploration.core.InvalidDestinationError...
3410        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
3411        Traceback (most recent call last):
3412        ...
3413        exploration.core.MissingTransitionError...
3414        >>> g.getReciprocal('G', 'up')
3415        'down'
3416        >>> g.getReciprocal('H', 'down')
3417        'up'
3418        >>> g.getReciprocal('H', 'return') is None
3419        True
3420        >>> g.setReciprocal('G', 'up', 'return')
3421        >>> g.getReciprocal('G', 'up')
3422        'return'
3423        >>> g.getReciprocal('H', 'down') is None
3424        True
3425        >>> g.getReciprocal('H', 'return')
3426        'up'
3427        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
3428        >>> g.getReciprocal('G', 'up') is None
3429        True
3430        >>> g.getReciprocal('H', 'down') is None
3431        True
3432        >>> g.getReciprocal('H', 'return') is None
3433        True
3434        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
3435        >>> g.getReciprocal('G', 'up')
3436        'down'
3437        >>> g.getReciprocal('H', 'down') is None
3438        True
3439        >>> g.getReciprocal('H', 'return') is None
3440        True
3441        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
3442        >>> g.getReciprocal('G', 'up')
3443        'down'
3444        >>> g.getReciprocal('H', 'down') is None
3445        True
3446        >>> g.getReciprocal('H', 'return')
3447        'up'
3448        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
3449        >>> g.getReciprocal('G', 'up')
3450        'down'
3451        >>> g.getReciprocal('H', 'down')
3452        'up'
3453        >>> g.getReciprocal('H', 'return') # unchanged
3454        'up'
3455        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
3456        >>> g.getReciprocal('G', 'up')
3457        'return'
3458        >>> g.getReciprocal('H', 'down')
3459        'up'
3460        >>> g.getReciprocal('H', 'return') # unchanged
3461        'up'
3462        >>> # Cleanup only applies to reciprocal if setBoth is true
3463        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
3464        >>> g.getReciprocal('G', 'up')
3465        'return'
3466        >>> g.getReciprocal('H', 'down')
3467        'up'
3468        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
3469        'up'
3470        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
3471        >>> g.getReciprocal('G', 'up')
3472        'down'
3473        >>> g.getReciprocal('H', 'down')
3474        'up'
3475        >>> g.getReciprocal('H', 'return') is None # cleaned up
3476        True
3477        """
3478        dID = self.resolveDecision(decision)
3479
3480        dest = self.destination(dID, transition) # possible KeyError
3481        if reciprocal is None:
3482            rDest = None
3483        else:
3484            rDest = self.getDestination(dest, reciprocal)
3485
3486        # Set or delete reciprocal property
3487        if reciprocal is None:
3488            # Delete the property
3489            info = self.edges[dID, dest, transition]  # type:ignore
3490
3491            old = info.pop('reciprocal')
3492            if setBoth:
3493                rDest = self.getDestination(dest, old)
3494                if rDest != dID:
3495                    raise RuntimeError(
3496                        f"Invalid reciprocal {old!r} for transition"
3497                        f" {transition!r} from {self.identityOf(dID)}:"
3498                        f" destination is {rDest}."
3499                    )
3500                rInfo = self.edges[dest, dID, old]  # type:ignore
3501                if 'reciprocal' in rInfo:
3502                    del rInfo['reciprocal']
3503        else:
3504            # Set the property, checking for errors first
3505            if rDest is None:
3506                raise MissingTransitionError(
3507                    f"Reciprocal transition {reciprocal!r} for"
3508                    f" transition {transition!r} from decision"
3509                    f" {self.identityOf(dID)} does not exist at"
3510                    f" decision {self.identityOf(dest)}"
3511                )
3512
3513            if rDest != dID:
3514                raise InvalidDestinationError(
3515                    f"Reciprocal transition {reciprocal!r} from"
3516                    f" decision {self.identityOf(dest)} does not lead"
3517                    f" back to decision {self.identityOf(dID)}."
3518                )
3519
3520            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
3521            abandoned = eProps.get('reciprocal')
3522            eProps['reciprocal'] = reciprocal
3523            if cleanup and abandoned not in (None, reciprocal):
3524                aProps = self.edges[dest, dID, abandoned]  # type:ignore
3525                if 'reciprocal' in aProps:
3526                    del aProps['reciprocal']
3527
3528            if setBoth:
3529                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
3530                revAbandoned = rProps.get('reciprocal')
3531                rProps['reciprocal'] = transition
3532                # Sever old reciprocal relationship
3533                if cleanup and revAbandoned not in (None, transition):
3534                    raProps = self.edges[
3535                        dID,  # type:ignore
3536                        dest,
3537                        revAbandoned
3538                    ]
3539                    del raProps['reciprocal']
3540
3541    def getReciprocalPair(
3542        self,
3543        decision: base.AnyDecisionSpecifier,
3544        transition: base.Transition
3545    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
3546        """
3547        Returns a tuple containing both the destination decision ID and
3548        the transition at that decision which is the reciprocal of the
3549        specified destination & transition. Returns `None` if no
3550        reciprocal has been established for that transition, or if that
3551        decision or transition does not exist.
3552
3553        >>> g = DecisionGraph()
3554        >>> g.addDecision('A')
3555        0
3556        >>> g.addDecision('B')
3557        1
3558        >>> g.addDecision('C')
3559        2
3560        >>> g.addTransition('A', 'up', 'B', 'down')
3561        >>> g.addTransition('B', 'right', 'C', 'left')
3562        >>> g.addTransition('A', 'oneway', 'C')
3563        >>> g.getReciprocalPair('A', 'up')
3564        (1, 'down')
3565        >>> g.getReciprocalPair('B', 'down')
3566        (0, 'up')
3567        >>> g.getReciprocalPair('B', 'right')
3568        (2, 'left')
3569        >>> g.getReciprocalPair('C', 'left')
3570        (1, 'right')
3571        >>> g.getReciprocalPair('C', 'up') is None
3572        True
3573        >>> g.getReciprocalPair('Q', 'up') is None
3574        True
3575        >>> g.getReciprocalPair('A', 'tunnel') is None
3576        True
3577        """
3578        try:
3579            dID = self.resolveDecision(decision)
3580        except MissingDecisionError:
3581            return None
3582
3583        reciprocal = self.getReciprocal(dID, transition)
3584        if reciprocal is None:
3585            return None
3586        else:
3587            destination = self.getDestination(dID, transition)
3588            if destination is None:
3589                return None
3590            else:
3591                return (destination, reciprocal)
3592
3593    def addDecision(
3594        self,
3595        name: base.DecisionName,
3596        domain: Optional[base.Domain] = None,
3597        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3598        annotations: Optional[List[base.Annotation]] = None
3599    ) -> base.DecisionID:
3600        """
3601        Adds a decision to the graph, without any transitions yet. Each
3602        decision will be assigned an ID so name collisions are allowed,
3603        but it's usually best to keep names unique at least within each
3604        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
3605        used for the decision's domain. A dictionary of tags and/or a
3606        list of annotations (strings in both cases) may be provided.
3607
3608        Returns the newly-assigned `DecisionID` for the decision it
3609        created.
3610
3611        Emits a `DecisionCollisionWarning` if a decision with the
3612        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
3613        global variable is set to `True`.
3614        """
3615        # Defaults
3616        if domain is None:
3617            domain = base.DEFAULT_DOMAIN
3618        if tags is None:
3619            tags = {}
3620        if annotations is None:
3621            annotations = []
3622
3623        # Error checking
3624        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3625            warnings.warn(
3626                (
3627                    f"Adding decision {name!r}: Another decision with"
3628                    f" that name already exists."
3629                ),
3630                DecisionCollisionWarning
3631            )
3632
3633        dID = self._assignID()
3634
3635        # Add the decision
3636        self.add_node(
3637            dID,
3638            name=name,
3639            domain=domain,
3640            tags=tags,
3641            annotations=annotations
3642        )
3643        #TODO: Elide tags/annotations if they're empty?
3644
3645        # Track it in our `nameLookup` dictionary
3646        self.nameLookup.setdefault(name, []).append(dID)
3647
3648        return dID
3649
3650    def addIdentifiedDecision(
3651        self,
3652        dID: base.DecisionID,
3653        name: base.DecisionName,
3654        domain: Optional[base.Domain] = None,
3655        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3656        annotations: Optional[List[base.Annotation]] = None
3657    ) -> None:
3658        """
3659        Adds a new decision to the graph using a specific decision ID,
3660        rather than automatically assigning a new decision ID like
3661        `addDecision` does. Otherwise works like `addDecision`.
3662
3663        Raises a `MechanismCollisionError` if the specified decision ID
3664        is already in use.
3665        """
3666        # Defaults
3667        if domain is None:
3668            domain = base.DEFAULT_DOMAIN
3669        if tags is None:
3670            tags = {}
3671        if annotations is None:
3672            annotations = []
3673
3674        # Error checking
3675        if dID in self.nodes:
3676            raise MechanismCollisionError(
3677                f"Cannot add a node with id {dID} and name {name!r}:"
3678                f" that ID is already used by node {self.identityOf(dID)}"
3679            )
3680
3681        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3682            warnings.warn(
3683                (
3684                    f"Adding decision {name!r}: Another decision with"
3685                    f" that name already exists."
3686                ),
3687                DecisionCollisionWarning
3688            )
3689
3690        # Add the decision
3691        self.add_node(
3692            dID,
3693            name=name,
3694            domain=domain,
3695            tags=tags,
3696            annotations=annotations
3697        )
3698        #TODO: Elide tags/annotations if they're empty?
3699
3700        # Track it in our `nameLookup` dictionary
3701        self.nameLookup.setdefault(name, []).append(dID)
3702
3703    def addTransition(
3704        self,
3705        fromDecision: base.AnyDecisionSpecifier,
3706        name: base.Transition,
3707        toDecision: base.AnyDecisionSpecifier,
3708        reciprocal: Optional[base.Transition] = None,
3709        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3710        annotations: Optional[List[base.Annotation]] = None,
3711        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
3712        revAnnotations: Optional[List[base.Annotation]] = None,
3713        requires: Optional[base.Requirement] = None,
3714        consequence: Optional[base.Consequence] = None,
3715        revRequires: Optional[base.Requirement] = None,
3716        revConsequece: Optional[base.Consequence] = None
3717    ) -> None:
3718        """
3719        Adds a transition connecting two decisions. A specifier for each
3720        decision is required, as is a name for the transition. If a
3721        `reciprocal` is provided, a reciprocal edge will be added in the
3722        opposite direction using that name; by default only the specified
3723        edge is added. A `TransitionCollisionError` will be raised if the
3724        `reciprocal` matches the name of an existing edge at the
3725        destination decision.
3726
3727        Both decisions must already exist, or a `MissingDecisionError`
3728        will be raised.
3729
3730        A dictionary of tags and/or a list of annotations may be
3731        provided. Tags and/or annotations for the reverse edge may also
3732        be specified if one is being added.
3733
3734        The `requires`, `consequence`, `revRequires`, and `revConsequece`
3735        arguments specify requirements and/or consequences of the new
3736        outgoing and reciprocal edges.
3737        """
3738        # Defaults
3739        if tags is None:
3740            tags = {}
3741        if annotations is None:
3742            annotations = []
3743        if revTags is None:
3744            revTags = {}
3745        if revAnnotations is None:
3746            revAnnotations = []
3747
3748        # Error checking
3749        fromID = self.resolveDecision(fromDecision)
3750        toID = self.resolveDecision(toDecision)
3751
3752        # Note: have to check this first so we don't add the forward edge
3753        # and then error out after a side effect!
3754        if (
3755            reciprocal is not None
3756        and self.getDestination(toDecision, reciprocal) is not None
3757        ):
3758            raise TransitionCollisionError(
3759                f"Cannot add a transition from"
3760                f" {self.identityOf(fromDecision)} to"
3761                f" {self.identityOf(toDecision)} with reciprocal edge"
3762                f" {reciprocal!r}: {reciprocal!r} is already used as an"
3763                f" edge name at {self.identityOf(toDecision)}."
3764            )
3765
3766        # Add the edge
3767        self.add_edge(
3768            fromID,
3769            toID,
3770            key=name,
3771            tags=tags,
3772            annotations=annotations
3773        )
3774        self.setTransitionRequirement(fromDecision, name, requires)
3775        if consequence is not None:
3776            self.setConsequence(fromDecision, name, consequence)
3777        if reciprocal is not None:
3778            # Add the reciprocal edge
3779            self.add_edge(
3780                toID,
3781                fromID,
3782                key=reciprocal,
3783                tags=revTags,
3784                annotations=revAnnotations
3785            )
3786            self.setReciprocal(fromID, name, reciprocal)
3787            self.setTransitionRequirement(
3788                toDecision,
3789                reciprocal,
3790                revRequires
3791            )
3792            if revConsequece is not None:
3793                self.setConsequence(toDecision, reciprocal, revConsequece)
3794
3795    def removeTransition(
3796        self,
3797        fromDecision: base.AnyDecisionSpecifier,
3798        transition: base.Transition,
3799        removeReciprocal=False
3800    ) -> Union[
3801        TransitionProperties,
3802        Tuple[TransitionProperties, TransitionProperties]
3803    ]:
3804        """
3805        Removes a transition. If `removeReciprocal` is true (False is the
3806        default) any reciprocal transition will also be removed (but no
3807        error will occur if there wasn't a reciprocal).
3808
3809        For each removed transition, *every* transition that targeted
3810        that transition as its reciprocal will have its reciprocal set to
3811        `None`, to avoid leaving any invalid reciprocal values.
3812
3813        Raises a `KeyError` if either the target decision or the target
3814        transition does not exist.
3815
3816        Returns a transition properties dictionary with the properties
3817        of the removed transition, or if `removeReciprocal` is true,
3818        returns a pair of such dictionaries for the target transition
3819        and its reciprocal.
3820
3821        ## Example
3822
3823        >>> g = DecisionGraph()
3824        >>> g.addDecision('A')
3825        0
3826        >>> g.addDecision('B')
3827        1
3828        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
3829        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
3830        >>> g.addTransition('A', 'next', 'B')
3831        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
3832        >>> p = g.removeTransition('A', 'up')
3833        >>> p['tags']
3834        {'wide'}
3835        >>> g.destinationsFrom('A')
3836        {'in': 1, 'next': 1}
3837        >>> g.destinationsFrom('B')
3838        {'down': 0, 'out': 0}
3839        >>> g.getReciprocal('B', 'down') is None
3840        True
3841        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
3842        'down'
3843        >>> g.getReciprocal('A', 'in') # not affected
3844        'out'
3845        >>> g.getReciprocal('B', 'out') # not affected
3846        'in'
3847        >>> # Now with removeReciprocal set to True
3848        >>> g.addTransition('A', 'up', 'B') # add this back in
3849        >>> g.setReciprocal('A', 'up', 'down') # sets both
3850        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
3851        >>> g.destinationsFrom('A')
3852        {'in': 1, 'next': 1}
3853        >>> g.destinationsFrom('B')
3854        {'out': 0}
3855        >>> g.getReciprocal('A', 'next') is None
3856        True
3857        >>> g.getReciprocal('A', 'in') # not affected
3858        'out'
3859        >>> g.getReciprocal('B', 'out') # not affected
3860        'in'
3861        >>> g.removeTransition('A', 'none')
3862        Traceback (most recent call last):
3863        ...
3864        exploration.core.MissingTransitionError...
3865        >>> g.removeTransition('Z', 'nope')
3866        Traceback (most recent call last):
3867        ...
3868        exploration.core.MissingDecisionError...
3869        """
3870        # Resolve target ID
3871        fromID = self.resolveDecision(fromDecision)
3872
3873        # raises if either is missing:
3874        destination = self.destination(fromID, transition)
3875        reciprocal = self.getReciprocal(fromID, transition)
3876
3877        # Get dictionaries of parallel & antiparallel edges to be
3878        # checked for invalid reciprocals after removing edges
3879        # Note: these will update live as we remove edges
3880        allAntiparallel = self[destination][fromID]
3881        allParallel = self[fromID][destination]
3882
3883        # Remove the target edge
3884        fProps = self.getTransitionProperties(fromID, transition)
3885        self.remove_edge(fromID, destination, transition)
3886
3887        # Clean up any dangling reciprocal values
3888        for tProps in allAntiparallel.values():
3889            if tProps.get('reciprocal') == transition:
3890                del tProps['reciprocal']
3891
3892        # Remove the reciprocal if requested
3893        if removeReciprocal and reciprocal is not None:
3894            rProps = self.getTransitionProperties(destination, reciprocal)
3895            self.remove_edge(destination, fromID, reciprocal)
3896
3897            # Clean up any dangling reciprocal values
3898            for tProps in allParallel.values():
3899                if tProps.get('reciprocal') == reciprocal:
3900                    del tProps['reciprocal']
3901
3902            return (fProps, rProps)
3903        else:
3904            return fProps
3905
3906    def addMechanism(
3907        self,
3908        name: base.MechanismName,
3909        where: Optional[base.AnyDecisionSpecifier] = None
3910    ) -> base.MechanismID:
3911        """
3912        Creates a new mechanism with the given name at the specified
3913        decision, returning its assigned ID. If `where` is `None`, it
3914        creates a global mechanism. Raises a `MechanismCollisionError`
3915        if a mechanism with the same name already exists at a specified
3916        decision (or already exists as a global mechanism).
3917
3918        Note that if the decision is deleted, the mechanism will be as
3919        well.
3920
3921        Since `MechanismState`s are not tracked by `DecisionGraph`s but
3922        instead are part of a `State`, the mechanism won't be in any
3923        particular state, which means it will be treated as being in the
3924        `base.DEFAULT_MECHANISM_STATE`.
3925        """
3926        if where is None:
3927            mechs = self.globalMechanisms
3928            dID = None
3929        else:
3930            dID = self.resolveDecision(where)
3931            mechs = self.nodes[dID].setdefault('mechanisms', {})
3932
3933        if name in mechs:
3934            if dID is None:
3935                raise MechanismCollisionError(
3936                    f"A global mechanism named {name!r} already exists."
3937                )
3938            else:
3939                raise MechanismCollisionError(
3940                    f"A mechanism named {name!r} already exists at"
3941                    f" decision {self.identityOf(dID)}."
3942                )
3943
3944        mID = self._assignMechanismID()
3945        mechs[name] = mID
3946        self.mechanisms[mID] = (dID, name)
3947        return mID
3948
3949    def mechanismsAt(
3950        self,
3951        decision: base.AnyDecisionSpecifier
3952    ) -> Dict[base.MechanismName, base.MechanismID]:
3953        """
3954        Returns a dictionary mapping mechanism names to their IDs for
3955        all mechanisms at the specified decision.
3956        """
3957        dID = self.resolveDecision(decision)
3958
3959        return self.nodes[dID]['mechanisms']
3960
3961    def mechanismDetails(
3962        self,
3963        mID: base.MechanismID
3964    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
3965        """
3966        Returns a tuple containing the decision ID and mechanism name
3967        for the specified mechanism. Returns `None` if there is no
3968        mechanism with that ID. For global mechanisms, `None` is used in
3969        place of a decision ID.
3970        """
3971        return self.mechanisms.get(mID)
3972
3973    def deleteMechanism(self, mID: base.MechanismID) -> None:
3974        """
3975        Deletes the specified mechanism.
3976        """
3977        name, dID = self.mechanisms.pop(mID)
3978
3979        del self.nodes[dID]['mechanisms'][name]
3980
3981    def localLookup(
3982        self,
3983        startFrom: Union[
3984            base.AnyDecisionSpecifier,
3985            Collection[base.AnyDecisionSpecifier]
3986        ],
3987        findAmong: Callable[
3988            ['DecisionGraph', Union[Set[base.DecisionID], str]],
3989            Optional[LookupResult]
3990        ],
3991        fallbackLayerName: Optional[str] = "fallback",
3992        fallbackToAllDecisions: bool = True
3993    ) -> Optional[LookupResult]:
3994        """
3995        Looks up some kind of result in the graph by starting from a
3996        base set of decisions and widening the search iteratively based
3997        on zones. This first searches for result(s) in the set of
3998        decisions given, then in the set of all decisions which are in
3999        level-0 zones containing those decisions, then in level-1 zones,
4000        etc. When it runs out of relevant zones, it will check all
4001        decisions which are in any domain that a decision from the
4002        initial search set is in, and then if `fallbackLayerName` is a
4003        string, it will provide that string instead of a set of decision
4004        IDs to the `findAmong` function as the next layer to search.
4005        After the `fallbackLayerName` is used, if
4006        `fallbackToAllDecisions` is `True` (the default) a final search
4007        will be run on all decisions in the graph. The provided
4008        `findAmong` function is called on each successive decision ID
4009        set, until it generates a non-`None` result. We stop and return
4010        that non-`None` result as soon as one is generated. But if none
4011        of the decision sets consulted generate non-`None` results, then
4012        the entire result will be `None`.
4013        """
4014        # Normalize starting decisions to a set
4015        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
4016            startFrom = set([startFrom])
4017
4018        # Resolve decision IDs; convert to list
4019        searchArea: Union[Set[base.DecisionID], str] = set(
4020            self.resolveDecision(spec) for spec in startFrom
4021        )
4022
4023        # Find all ancestor zones & all relevant domains
4024        allAncestors = set()
4025        relevantDomains = set()
4026        for startingDecision in searchArea:
4027            allAncestors |= self.zoneAncestors(startingDecision)
4028            relevantDomains.add(self.domainFor(startingDecision))
4029
4030        # Build layers dictionary
4031        ancestorLayers: Dict[int, Set[base.Zone]] = {}
4032        for zone in allAncestors:
4033            info = self.getZoneInfo(zone)
4034            assert info is not None
4035            level = info.level
4036            ancestorLayers.setdefault(level, set()).add(zone)
4037
4038        searchLayers: LookupLayersList = (
4039            cast(LookupLayersList, [None])
4040          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
4041          + cast(LookupLayersList, ["domains"])
4042        )
4043        if fallbackLayerName is not None:
4044            searchLayers.append("fallback")
4045
4046        if fallbackToAllDecisions:
4047            searchLayers.append("all")
4048
4049        # Continue our search through zone layers
4050        for layer in searchLayers:
4051            # Update search area on subsequent iterations
4052            if layer == "domains":
4053                searchArea = set()
4054                for relevant in relevantDomains:
4055                    searchArea |= self.allDecisionsInDomain(relevant)
4056            elif layer == "fallback":
4057                assert fallbackLayerName is not None
4058                searchArea = fallbackLayerName
4059            elif layer == "all":
4060                searchArea = set(self.nodes)
4061            elif layer is not None:
4062                layer = cast(int, layer)  # must be an integer
4063                searchZones = ancestorLayers[layer]
4064                searchArea = set()
4065                for zone in searchZones:
4066                    searchArea |= self.allDecisionsInZone(zone)
4067            # else it's the first iteration and we use the starting
4068            # searchArea
4069
4070            searchResult: Optional[LookupResult] = findAmong(
4071                self,
4072                searchArea
4073            )
4074
4075            if searchResult is not None:
4076                return searchResult
4077
4078        # Didn't find any non-None results.
4079        return None
4080
4081    @staticmethod
4082    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
4083        ['DecisionGraph', Union[Set[base.DecisionID], str]],
4084        Optional[base.MechanismID]
4085    ]:
4086        """
4087        Returns a search function that looks for the given mechanism ID,
4088        suitable for use with `localLookup`. The finder will raise a
4089        `MechanismCollisionError` if it finds more than one mechanism
4090        with the specified name at the same level of the search.
4091        """
4092        def namedMechanismFinder(
4093            graph: 'DecisionGraph',
4094            searchIn: Union[Set[base.DecisionID], str]
4095        ) -> Optional[base.MechanismID]:
4096            """
4097            Generated finder function for `localLookup` to find a unique
4098            mechanism by name.
4099            """
4100            candidates: List[base.DecisionID] = []
4101
4102            if searchIn == "fallback":
4103                if name in graph.globalMechanisms:
4104                    candidates = [graph.globalMechanisms[name]]
4105
4106            else:
4107                assert isinstance(searchIn, set)
4108                for dID in searchIn:
4109                    mechs = graph.nodes[dID].get('mechanisms', {})
4110                    if name in mechs:
4111                        candidates.append(mechs[name])
4112
4113            if len(candidates) > 1:
4114                raise MechanismCollisionError(
4115                    f"There are {len(candidates)} mechanisms named {name!r}"
4116                    f" in the search area ({len(searchIn)} decisions(s))."
4117                )
4118            elif len(candidates) == 1:
4119                return candidates[0]
4120            else:
4121                return None
4122
4123        return namedMechanismFinder
4124
4125    def lookupMechanism(
4126        self,
4127        startFrom: Union[
4128            base.AnyDecisionSpecifier,
4129            Collection[base.AnyDecisionSpecifier]
4130        ],
4131        name: base.MechanismName
4132    ) -> base.MechanismID:
4133        """
4134        Looks up the mechanism with the given name 'closest' to the
4135        given decision or set of decisions. First it looks for a
4136        mechanism with that name that's at one of those decisions. Then
4137        it starts looking in level-0 zones which contain any of them,
4138        then in level-1 zones, and so on. If it finds two mechanisms
4139        with the target name during the same search pass, it raises a
4140        `MechanismCollisionError`, but if it finds one it returns it.
4141        Raises a `MissingMechanismError` if there is no mechanisms with
4142        that name among global mechanisms (searched after the last
4143        applicable level of zones) or anywhere in the graph (which is the
4144        final level of search after checking global mechanisms).
4145
4146        For example:
4147
4148        >>> d = DecisionGraph()
4149        >>> d.addDecision('A')
4150        0
4151        >>> d.addDecision('B')
4152        1
4153        >>> d.addDecision('C')
4154        2
4155        >>> d.addDecision('D')
4156        3
4157        >>> d.addDecision('E')
4158        4
4159        >>> d.addMechanism('switch', 'A')
4160        0
4161        >>> d.addMechanism('switch', 'B')
4162        1
4163        >>> d.addMechanism('switch', 'C')
4164        2
4165        >>> d.addMechanism('lever', 'D')
4166        3
4167        >>> d.addMechanism('lever', None)  # global
4168        4
4169        >>> d.createZone('Z1', 0)
4170        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4171 annotations=[])
4172        >>> d.createZone('Z2', 0)
4173        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4174 annotations=[])
4175        >>> d.createZone('Zup', 1)
4176        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
4177 annotations=[])
4178        >>> d.addDecisionToZone('A', 'Z1')
4179        >>> d.addDecisionToZone('B', 'Z1')
4180        >>> d.addDecisionToZone('C', 'Z2')
4181        >>> d.addDecisionToZone('D', 'Z2')
4182        >>> d.addDecisionToZone('E', 'Z1')
4183        >>> d.addZoneToZone('Z1', 'Zup')
4184        >>> d.addZoneToZone('Z2', 'Zup')
4185        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
4186        Traceback (most recent call last):
4187        ...
4188        exploration.core.MechanismCollisionError...
4189        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
4190        4
4191        >>> d.lookupMechanism({'D'}, 'lever')  # local
4192        3
4193        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
4194        3
4195        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
4196        3
4197        >>> d.lookupMechanism({'A'}, 'switch')  # local
4198        0
4199        >>> d.lookupMechanism({'B'}, 'switch')  # local
4200        1
4201        >>> d.lookupMechanism({'C'}, 'switch')  # local
4202        2
4203        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
4204        Traceback (most recent call last):
4205        ...
4206        exploration.core.MechanismCollisionError...
4207        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
4208        Traceback (most recent call last):
4209        ...
4210        exploration.core.MechanismCollisionError...
4211        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
4212        1
4213        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
4214        Traceback (most recent call last):
4215        ...
4216        exploration.core.MechanismCollisionError...
4217        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
4218        Traceback (most recent call last):
4219        ...
4220        exploration.core.MechanismCollisionError...
4221        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
4222        2
4223        """
4224        result = self.localLookup(
4225            startFrom,
4226            DecisionGraph.uniqueMechanismFinder(name)
4227        )
4228        if result is None:
4229            raise MissingMechanismError(
4230                f"No mechanism named {name!r}"
4231            )
4232        else:
4233            return result
4234
4235    def resolveMechanism(
4236        self,
4237        specifier: base.AnyMechanismSpecifier,
4238        startFrom: Union[
4239            None,
4240            base.AnyDecisionSpecifier,
4241            Collection[base.AnyDecisionSpecifier]
4242        ] = None
4243    ) -> base.MechanismID:
4244        """
4245        Works like `lookupMechanism`, except it accepts a
4246        `base.AnyMechanismSpecifier` which may have position information
4247        baked in, and so the `startFrom` information is optional. If
4248        position information isn't specified in the mechanism specifier
4249        and startFrom is not provided, the mechanism is searched for at
4250        the global scope and then in the entire graph. On the other
4251        hand, if the specifier includes any position information, the
4252        startFrom value provided here will be ignored.
4253        """
4254        if isinstance(specifier, base.MechanismID):
4255            return specifier
4256
4257        elif isinstance(specifier, base.MechanismName):
4258            if startFrom is None:
4259                startFrom = set()
4260            return self.lookupMechanism(startFrom, specifier)
4261
4262        elif isinstance(specifier, tuple) and len(specifier) == 4:
4263            domain, zone, decision, mechanism = specifier
4264            if domain is None and zone is None and decision is None:
4265                if startFrom is None:
4266                    startFrom = set()
4267                return self.lookupMechanism(startFrom, mechanism)
4268
4269            elif decision is not None:
4270                startFrom = {
4271                    self.resolveDecision(
4272                        base.DecisionSpecifier(domain, zone, decision)
4273                    )
4274                }
4275                return self.lookupMechanism(startFrom, mechanism)
4276
4277            else:  # decision is None but domain and/or zone aren't
4278                startFrom = set()
4279                if zone is not None:
4280                    baseStart = self.allDecisionsInZone(zone)
4281                else:
4282                    baseStart = set(self)
4283
4284                if domain is None:
4285                    startFrom = baseStart
4286                else:
4287                    for dID in baseStart:
4288                        if self.domainFor(dID) == domain:
4289                            startFrom.add(dID)
4290                return self.lookupMechanism(startFrom, mechanism)
4291
4292        else:
4293            raise TypeError(
4294                f"Invalid mechanism specifier: {repr(specifier)}"
4295                f"\n(Must be a mechanism ID, mechanism name, or"
4296                f" mechanism specifier tuple)"
4297            )
4298
4299    def walkConsequenceMechanisms(
4300        self,
4301        consequence: base.Consequence,
4302        searchFrom: Set[base.DecisionID]
4303    ) -> Generator[base.MechanismID, None, None]:
4304        """
4305        Yields each requirement in the given `base.Consequence`,
4306        including those in `base.Condition`s, `base.ConditionalSkill`s
4307        within `base.Challenge`s, and those set or toggled by
4308        `base.Effect`s. The `searchFrom` argument specifies where to
4309        start searching for mechanisms, since requirements include them
4310        by name, not by ID.
4311        """
4312        for part in base.walkParts(consequence):
4313            if isinstance(part, dict):
4314                if 'skills' in part:  # a Challenge
4315                    for cSkill in part['skills'].walk():
4316                        if isinstance(cSkill, base.ConditionalSkill):
4317                            yield from self.walkRequirementMechanisms(
4318                                cSkill.requirement,
4319                                searchFrom
4320                            )
4321                elif 'condition' in part:  # a Condition
4322                    yield from self.walkRequirementMechanisms(
4323                        part['condition'],
4324                        searchFrom
4325                    )
4326                elif 'value' in part:  # an Effect
4327                    val = part['value']
4328                    if part['type'] == 'set':
4329                        if (
4330                            isinstance(val, tuple)
4331                        and len(val) == 2
4332                        and isinstance(val[1], base.State)
4333                        ):
4334                            yield from self.walkRequirementMechanisms(
4335                                base.ReqMechanism(val[0], val[1]),
4336                                searchFrom
4337                            )
4338                    elif part['type'] == 'toggle':
4339                        if isinstance(val, tuple):
4340                            assert len(val) == 2
4341                            yield from self.walkRequirementMechanisms(
4342                                base.ReqMechanism(val[0], '_'),
4343                                  # state part is ignored here
4344                                searchFrom
4345                            )
4346
4347    def walkRequirementMechanisms(
4348        self,
4349        req: base.Requirement,
4350        searchFrom: Set[base.DecisionID]
4351    ) -> Generator[base.MechanismID, None, None]:
4352        """
4353        Given a requirement, yields any mechanisms mentioned in that
4354        requirement, in depth-first traversal order.
4355        """
4356        for part in req.walk():
4357            if isinstance(part, base.ReqMechanism):
4358                mech = part.mechanism
4359                yield self.resolveMechanism(
4360                    mech,
4361                    startFrom=searchFrom
4362                )
4363
4364    def addUnexploredEdge(
4365        self,
4366        fromDecision: base.AnyDecisionSpecifier,
4367        name: base.Transition,
4368        destinationName: Optional[base.DecisionName] = None,
4369        reciprocal: Optional[base.Transition] = 'return',
4370        toDomain: Optional[base.Domain] = None,
4371        placeInZone: Optional[base.Zone] = None,
4372        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
4373        annotations: Optional[List[base.Annotation]] = None,
4374        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
4375        revAnnotations: Optional[List[base.Annotation]] = None,
4376        requires: Optional[base.Requirement] = None,
4377        consequence: Optional[base.Consequence] = None,
4378        revRequires: Optional[base.Requirement] = None,
4379        revConsequece: Optional[base.Consequence] = None
4380    ) -> base.DecisionID:
4381        """
4382        Adds a transition connecting to a new decision named `'_u.-n-'`
4383        where '-n-' is the number of unknown decisions (named or not)
4384        that have ever been created in this graph (or using the
4385        specified destination name if one is provided). This represents
4386        a transition to an unknown destination. The destination node
4387        gets tagged 'unconfirmed'.
4388
4389        This also adds a reciprocal transition in the reverse direction,
4390        unless `reciprocal` is set to `None`. The reciprocal will use
4391        the provided name (default is 'return'). The new decision will
4392        be in the same domain as the decision it's connected to, unless
4393        `toDecision` is specified, in which case it will be in that
4394        domain.
4395
4396        The new decision will not be placed into any zones, unless
4397        `placeInZone` is specified, in which case it will be placed into
4398        that zone. If that zone needs to be created, it will be created
4399        at level 0; in that case that zone will be added to any
4400        grandparent zones of the decision we're branching off of. If
4401        `placeInZone` is set to `base.DefaultZone`, then the new
4402        decision will be placed into each parent zone of the decision
4403        we're branching off of, as long as the new decision is in the
4404        same domain as the decision we're branching from (otherwise only
4405        an explicit `placeInZone` would apply).
4406
4407        The ID of the decision that was created is returned.
4408
4409        A `MissingDecisionError` will be raised if the starting decision
4410        does not exist, a `TransitionCollisionError` will be raised if
4411        it exists but already has a transition with the given name, and a
4412        `DecisionCollisionWarning` will be issued if a decision with the
4413        specified destination name already exists (won't happen when
4414        using an automatic name).
4415
4416        Lists of tags and/or annotations (strings in both cases) may be
4417        provided. These may also be provided for the reciprocal edge.
4418
4419        Similarly, requirements and/or consequences for either edge may
4420        be provided.
4421
4422        ## Example
4423
4424        >>> g = DecisionGraph()
4425        >>> g.addDecision('A')
4426        0
4427        >>> g.addUnexploredEdge('A', 'up')
4428        1
4429        >>> g.nameFor(1)
4430        '_u.0'
4431        >>> g.decisionTags(1)
4432        {'unconfirmed': 1}
4433        >>> g.addUnexploredEdge('A', 'right', 'B')
4434        2
4435        >>> g.nameFor(2)
4436        'B'
4437        >>> g.decisionTags(2)
4438        {'unconfirmed': 1}
4439        >>> g.addUnexploredEdge('A', 'down', None, 'up')
4440        3
4441        >>> g.nameFor(3)
4442        '_u.2'
4443        >>> g.addUnexploredEdge(
4444        ...    '_u.0',
4445        ...    'beyond',
4446        ...    toDomain='otherDomain',
4447        ...    tags={'fast':1},
4448        ...    revTags={'slow':1},
4449        ...    annotations=['comment'],
4450        ...    revAnnotations=['one', 'two'],
4451        ...    requires=base.ReqCapability('dash'),
4452        ...    revRequires=base.ReqCapability('super dash'),
4453        ...    consequence=[base.effect(gain='super dash')],
4454        ...    revConsequece=[base.effect(lose='super dash')]
4455        ... )
4456        4
4457        >>> g.nameFor(4)
4458        '_u.3'
4459        >>> g.domainFor(4)
4460        'otherDomain'
4461        >>> g.transitionTags('_u.0', 'beyond')
4462        {'fast': 1}
4463        >>> g.transitionAnnotations('_u.0', 'beyond')
4464        ['comment']
4465        >>> g.getTransitionRequirement('_u.0', 'beyond')
4466        ReqCapability('dash')
4467        >>> e = g.getConsequence('_u.0', 'beyond')
4468        >>> e == [base.effect(gain='super dash')]
4469        True
4470        >>> g.transitionTags('_u.3', 'return')
4471        {'slow': 1}
4472        >>> g.transitionAnnotations('_u.3', 'return')
4473        ['one', 'two']
4474        >>> g.getTransitionRequirement('_u.3', 'return')
4475        ReqCapability('super dash')
4476        >>> e = g.getConsequence('_u.3', 'return')
4477        >>> e == [base.effect(lose='super dash')]
4478        True
4479        """
4480        # Defaults
4481        if tags is None:
4482            tags = {}
4483        if annotations is None:
4484            annotations = []
4485        if revTags is None:
4486            revTags = {}
4487        if revAnnotations is None:
4488            revAnnotations = []
4489
4490        # Resolve ID
4491        fromID = self.resolveDecision(fromDecision)
4492        if toDomain is None:
4493            toDomain = self.domainFor(fromID)
4494
4495        if name in self.destinationsFrom(fromID):
4496            raise TransitionCollisionError(
4497                f"Cannot add a new edge {name!r}:"
4498                f" {self.identityOf(fromDecision)} already has an"
4499                f" outgoing edge with that name."
4500            )
4501
4502        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
4503            warnings.warn(
4504                (
4505                    f"Cannot add a new unexplored node"
4506                    f" {destinationName!r}: A decision with that name"
4507                    f" already exists.\n(Leave destinationName as None"
4508                    f" to use an automatic name.)"
4509                ),
4510                DecisionCollisionWarning
4511            )
4512
4513        # Create the new unexplored decision and add the edge
4514        if destinationName is None:
4515            toName = '_u.' + str(self.unknownCount)
4516        else:
4517            toName = destinationName
4518        self.unknownCount += 1
4519        newID = self.addDecision(toName, domain=toDomain)
4520        self.addTransition(
4521            fromID,
4522            name,
4523            newID,
4524            tags=tags,
4525            annotations=annotations
4526        )
4527        self.setTransitionRequirement(fromID, name, requires)
4528        if consequence is not None:
4529            self.setConsequence(fromID, name, consequence)
4530
4531        # Add it to a zone if requested
4532        if (
4533            placeInZone == base.DefaultZone
4534        and toDomain == self.domainFor(fromID)
4535        ):
4536            # Add to each parent of the from decision
4537            for parent in self.zoneParents(fromID):
4538                self.addDecisionToZone(newID, parent)
4539        elif placeInZone is not None:
4540            # Otherwise add it to one specific zone, creating that zone
4541            # at level 0 if necessary
4542            assert isinstance(placeInZone, base.Zone)
4543            if self.getZoneInfo(placeInZone) is None:
4544                self.createZone(placeInZone, 0)
4545                # Add new zone to each grandparent of the from decision
4546                for parent in self.zoneParents(fromID):
4547                    for grandparent in self.zoneParents(parent):
4548                        self.addZoneToZone(placeInZone, grandparent)
4549            self.addDecisionToZone(newID, placeInZone)
4550
4551        # Create the reciprocal edge
4552        if reciprocal is not None:
4553            self.addTransition(
4554                newID,
4555                reciprocal,
4556                fromID,
4557                tags=revTags,
4558                annotations=revAnnotations
4559            )
4560            self.setTransitionRequirement(newID, reciprocal, revRequires)
4561            if revConsequece is not None:
4562                self.setConsequence(newID, reciprocal, revConsequece)
4563            # Set as a reciprocal
4564            self.setReciprocal(fromID, name, reciprocal)
4565
4566        # Tag the destination as 'unconfirmed'
4567        self.tagDecision(newID, 'unconfirmed')
4568
4569        # Return ID of new destination
4570        return newID
4571
4572    def retargetTransition(
4573        self,
4574        fromDecision: base.AnyDecisionSpecifier,
4575        transition: base.Transition,
4576        newDestination: base.AnyDecisionSpecifier,
4577        swapReciprocal=True,
4578        errorOnNameColision=True
4579    ) -> Optional[base.Transition]:
4580        """
4581        Given a particular decision and a transition at that decision,
4582        changes that transition so that it goes to the specified new
4583        destination instead of wherever it was connected to before. If
4584        the new destination is the same as the old one, no changes are
4585        made.
4586
4587        If `swapReciprocal` is set to True (the default) then any
4588        reciprocal edge at the old destination will be deleted, and a
4589        new reciprocal edge from the new destination with equivalent
4590        properties to the original reciprocal will be created, pointing
4591        to the origin of the specified transition. If `swapReciprocal`
4592        is set to False, then the reciprocal relationship with any old
4593        reciprocal edge will be removed, but the old reciprocal edge
4594        will not be changed.
4595
4596        Note that if `errorOnNameColision` is True (the default), then
4597        if the reciprocal transition has the same name as a transition
4598        which already exists at the new destination node, a
4599        `TransitionCollisionError` will be thrown. However, if it is set
4600        to False, the reciprocal transition will be renamed with a suffix
4601        to avoid any possible name collisions. Either way, the name of
4602        the reciprocal transition (possibly just changed) will be
4603        returned, or None if there was no reciprocal transition.
4604
4605        ## Example
4606
4607        >>> g = DecisionGraph()
4608        >>> for fr, to, nm in [
4609        ...     ('A', 'B', 'up'),
4610        ...     ('A', 'B', 'up2'),
4611        ...     ('B', 'A', 'down'),
4612        ...     ('B', 'B', 'self'),
4613        ...     ('B', 'C', 'next'),
4614        ...     ('C', 'B', 'prev')
4615        ... ]:
4616        ...     if g.getDecision(fr) is None:
4617        ...        g.addDecision(fr)
4618        ...     if g.getDecision(to) is None:
4619        ...         g.addDecision(to)
4620        ...     g.addTransition(fr, nm, to)
4621        0
4622        1
4623        2
4624        >>> g.setReciprocal('A', 'up', 'down')
4625        >>> g.setReciprocal('B', 'next', 'prev')
4626        >>> g.destination('A', 'up')
4627        1
4628        >>> g.destination('B', 'down')
4629        0
4630        >>> g.retargetTransition('A', 'up', 'C')
4631        'down'
4632        >>> g.destination('A', 'up')
4633        2
4634        >>> g.getDestination('B', 'down') is None
4635        True
4636        >>> g.destination('C', 'down')
4637        0
4638        >>> g.addTransition('A', 'next', 'B')
4639        >>> g.addTransition('B', 'prev', 'A')
4640        >>> g.setReciprocal('A', 'next', 'prev')
4641        >>> # Can't swap a reciprocal in a way that would collide names
4642        >>> g.getReciprocal('C', 'prev')
4643        'next'
4644        >>> g.retargetTransition('C', 'prev', 'A')
4645        Traceback (most recent call last):
4646        ...
4647        exploration.core.TransitionCollisionError...
4648        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
4649        'next'
4650        >>> g.destination('C', 'prev')
4651        0
4652        >>> g.destination('A', 'next') # not changed
4653        1
4654        >>> # Reciprocal relationship is severed:
4655        >>> g.getReciprocal('C', 'prev') is None
4656        True
4657        >>> g.getReciprocal('B', 'next') is None
4658        True
4659        >>> # Swap back so we can do another demo
4660        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
4661        >>> # Note return value was None here because there was no reciprocal
4662        >>> g.setReciprocal('C', 'prev', 'next')
4663        >>> # Swap reciprocal by renaming it
4664        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
4665        'next.1'
4666        >>> g.getReciprocal('C', 'prev')
4667        'next.1'
4668        >>> g.destination('C', 'prev')
4669        0
4670        >>> g.destination('A', 'next.1')
4671        2
4672        >>> g.destination('A', 'next')
4673        1
4674        >>> # Note names are the same but these are from different nodes
4675        >>> g.getReciprocal('A', 'next')
4676        'prev'
4677        >>> g.getReciprocal('A', 'next.1')
4678        'prev'
4679        """
4680        fromID = self.resolveDecision(fromDecision)
4681        newDestID = self.resolveDecision(newDestination)
4682
4683        # Figure out the old destination of the transition we're swapping
4684        oldDestID = self.destination(fromID, transition)
4685        reciprocal = self.getReciprocal(fromID, transition)
4686
4687        # If thew new destination is the same, we don't do anything!
4688        if oldDestID == newDestID:
4689            return reciprocal
4690
4691        # First figure out reciprocal business so we can error out
4692        # without making changes if we need to
4693        if swapReciprocal and reciprocal is not None:
4694            reciprocal = self.rebaseTransition(
4695                oldDestID,
4696                reciprocal,
4697                newDestID,
4698                swapReciprocal=False,
4699                errorOnNameColision=errorOnNameColision
4700            )
4701
4702        # Handle the forward transition...
4703        # Find the transition properties
4704        tProps = self.getTransitionProperties(fromID, transition)
4705
4706        # Delete the edge
4707        self.removeEdgeByKey(fromID, transition)
4708
4709        # Add the new edge
4710        self.addTransition(fromID, transition, newDestID)
4711
4712        # Reapply the transition properties
4713        self.setTransitionProperties(fromID, transition, **tProps)
4714
4715        # Handle the reciprocal transition if there is one...
4716        if reciprocal is not None:
4717            if not swapReciprocal:
4718                # Then sever the relationship, but only if that edge
4719                # still exists (we might be in the middle of a rebase)
4720                check = self.getDestination(oldDestID, reciprocal)
4721                if check is not None:
4722                    self.setReciprocal(
4723                        oldDestID,
4724                        reciprocal,
4725                        None,
4726                        setBoth=False # Other transition was deleted already
4727                    )
4728            else:
4729                # Establish new reciprocal relationship
4730                self.setReciprocal(
4731                    fromID,
4732                    transition,
4733                    reciprocal
4734                )
4735
4736        return reciprocal
4737
4738    def rebaseTransition(
4739        self,
4740        fromDecision: base.AnyDecisionSpecifier,
4741        transition: base.Transition,
4742        newBase: base.AnyDecisionSpecifier,
4743        swapReciprocal=True,
4744        errorOnNameColision=True
4745    ) -> base.Transition:
4746        """
4747        Given a particular destination and a transition at that
4748        destination, changes that transition's origin to a new base
4749        decision. If the new source is the same as the old one, no
4750        changes are made.
4751
4752        If `swapReciprocal` is set to True (the default) then any
4753        reciprocal edge at the destination will be retargeted to point
4754        to the new source so that it can remain a reciprocal. If
4755        `swapReciprocal` is set to False, then the reciprocal
4756        relationship with any old reciprocal edge will be removed, but
4757        the old reciprocal edge will not be otherwise changed.
4758
4759        Note that if `errorOnNameColision` is True (the default), then
4760        if the transition has the same name as a transition which
4761        already exists at the new source node, a
4762        `TransitionCollisionError` will be raised. However, if it is set
4763        to False, the transition will be renamed with a suffix to avoid
4764        any possible name collisions. Either way, the (possibly new) name
4765        of the transition that was rebased will be returned.
4766
4767        ## Example
4768
4769        >>> g = DecisionGraph()
4770        >>> for fr, to, nm in [
4771        ...     ('A', 'B', 'up'),
4772        ...     ('A', 'B', 'up2'),
4773        ...     ('B', 'A', 'down'),
4774        ...     ('B', 'B', 'self'),
4775        ...     ('B', 'C', 'next'),
4776        ...     ('C', 'B', 'prev')
4777        ... ]:
4778        ...     if g.getDecision(fr) is None:
4779        ...        g.addDecision(fr)
4780        ...     if g.getDecision(to) is None:
4781        ...         g.addDecision(to)
4782        ...     g.addTransition(fr, nm, to)
4783        0
4784        1
4785        2
4786        >>> g.setReciprocal('A', 'up', 'down')
4787        >>> g.setReciprocal('B', 'next', 'prev')
4788        >>> g.destination('A', 'up')
4789        1
4790        >>> g.destination('B', 'down')
4791        0
4792        >>> g.rebaseTransition('B', 'down', 'C')
4793        'down'
4794        >>> g.destination('A', 'up')
4795        2
4796        >>> g.getDestination('B', 'down') is None
4797        True
4798        >>> g.destination('C', 'down')
4799        0
4800        >>> g.addTransition('A', 'next', 'B')
4801        >>> g.addTransition('B', 'prev', 'A')
4802        >>> g.setReciprocal('A', 'next', 'prev')
4803        >>> # Can't rebase in a way that would collide names
4804        >>> g.rebaseTransition('B', 'next', 'A')
4805        Traceback (most recent call last):
4806        ...
4807        exploration.core.TransitionCollisionError...
4808        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
4809        'next.1'
4810        >>> g.destination('C', 'prev')
4811        0
4812        >>> g.destination('A', 'next') # not changed
4813        1
4814        >>> # Collision is avoided by renaming
4815        >>> g.destination('A', 'next.1')
4816        2
4817        >>> # Swap without reciprocal
4818        >>> g.getReciprocal('A', 'next.1')
4819        'prev'
4820        >>> g.getReciprocal('C', 'prev')
4821        'next.1'
4822        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
4823        'next.1'
4824        >>> g.getReciprocal('C', 'prev') is None
4825        True
4826        >>> g.destination('C', 'prev')
4827        0
4828        >>> g.getDestination('A', 'next.1') is None
4829        True
4830        >>> g.destination('A', 'next')
4831        1
4832        >>> g.destination('B', 'next.1')
4833        2
4834        >>> g.getReciprocal('B', 'next.1') is None
4835        True
4836        >>> # Rebase in a way that creates a self-edge
4837        >>> g.rebaseTransition('A', 'next', 'B')
4838        'next'
4839        >>> g.getDestination('A', 'next') is None
4840        True
4841        >>> g.destination('B', 'next')
4842        1
4843        >>> g.destination('B', 'prev') # swapped as a reciprocal
4844        1
4845        >>> g.getReciprocal('B', 'next') # still reciprocals
4846        'prev'
4847        >>> g.getReciprocal('B', 'prev')
4848        'next'
4849        >>> # And rebasing of a self-edge also works
4850        >>> g.rebaseTransition('B', 'prev', 'A')
4851        'prev'
4852        >>> g.destination('A', 'prev')
4853        1
4854        >>> g.destination('B', 'next')
4855        0
4856        >>> g.getReciprocal('B', 'next') # still reciprocals
4857        'prev'
4858        >>> g.getReciprocal('A', 'prev')
4859        'next'
4860        >>> # We've effectively reversed this edge/reciprocal pair
4861        >>> # by rebasing twice
4862        """
4863        fromID = self.resolveDecision(fromDecision)
4864        newBaseID = self.resolveDecision(newBase)
4865
4866        # If thew new base is the same, we don't do anything!
4867        if newBaseID == fromID:
4868            return transition
4869
4870        # First figure out reciprocal business so we can swap it later
4871        # without making changes if we need to
4872        destination = self.destination(fromID, transition)
4873        reciprocal = self.getReciprocal(fromID, transition)
4874        # Check for an already-deleted reciprocal
4875        if (
4876            reciprocal is not None
4877        and self.getDestination(destination, reciprocal) is None
4878        ):
4879            reciprocal = None
4880
4881        # Handle the base swap...
4882        # Find the transition properties
4883        tProps = self.getTransitionProperties(fromID, transition)
4884
4885        # Check for a collision
4886        targetDestinations = self.destinationsFrom(newBaseID)
4887        if transition in targetDestinations:
4888            if errorOnNameColision:
4889                raise TransitionCollisionError(
4890                    f"Cannot rebase transition {transition!r} from"
4891                    f" {self.identityOf(fromDecision)}: it would be a"
4892                    f" duplicate transition name at the new base"
4893                    f" decision {self.identityOf(newBase)}."
4894                )
4895            else:
4896                # Figure out a good fresh name
4897                newName = utils.uniqueName(
4898                    transition,
4899                    targetDestinations
4900                )
4901        else:
4902            newName = transition
4903
4904        # Delete the edge
4905        self.removeEdgeByKey(fromID, transition)
4906
4907        # Add the new edge
4908        self.addTransition(newBaseID, newName, destination)
4909
4910        # Reapply the transition properties
4911        self.setTransitionProperties(newBaseID, newName, **tProps)
4912
4913        # Handle the reciprocal transition if there is one...
4914        if reciprocal is not None:
4915            if not swapReciprocal:
4916                # Then sever the relationship
4917                self.setReciprocal(
4918                    destination,
4919                    reciprocal,
4920                    None,
4921                    setBoth=False # Other transition was deleted already
4922                )
4923            else:
4924                # Otherwise swap the reciprocal edge
4925                self.retargetTransition(
4926                    destination,
4927                    reciprocal,
4928                    newBaseID,
4929                    swapReciprocal=False
4930                )
4931
4932                # And establish a new reciprocal relationship
4933                self.setReciprocal(
4934                    newBaseID,
4935                    newName,
4936                    reciprocal
4937                )
4938
4939        # Return the new name in case it was changed
4940        return newName
4941
4942    # TODO: zone merging!
4943
4944    # TODO: Double-check that exploration vars get updated when this is
4945    # called!
4946    def mergeDecisions(
4947        self,
4948        merge: base.AnyDecisionSpecifier,
4949        mergeInto: base.AnyDecisionSpecifier,
4950        errorOnNameColision=True
4951    ) -> Dict[base.Transition, base.Transition]:
4952        """
4953        Merges two decisions, deleting the first after transferring all
4954        of its incoming and outgoing edges to target the second one,
4955        whose name is retained. The second decision will be added to any
4956        zones that the first decision was a member of. If either decision
4957        does not exist, a `MissingDecisionError` will be raised. If
4958        `merge` and `mergeInto` are the same, then nothing will be
4959        changed.
4960
4961        Unless `errorOnNameColision` is set to False, a
4962        `TransitionCollisionError` will be raised if the two decisions
4963        have outgoing transitions with the same name. If
4964        `errorOnNameColision` is set to False, then such edges will be
4965        renamed using a suffix to avoid name collisions, with edges
4966        connected to the second decision retaining their original names
4967        and edges that were connected to the first decision getting
4968        renamed.
4969
4970        Any mechanisms located at the first decision will be moved to the
4971        merged decision.
4972
4973        The tags and annotations of the merged decision are added to the
4974        tags and annotations of the merge target. If there are shared
4975        tags, the values from the merge target will override those of
4976        the merged decision. If this is undesired behavior, clear/edit
4977        the tags/annotations of the merged decision before the merge.
4978
4979        The 'unconfirmed' tag is treated specially: if both decisions have
4980        it it will be retained, but otherwise it will be dropped even if
4981        one of the situations had it before.
4982
4983        The domain of the second decision is retained.
4984
4985        Returns a dictionary mapping each original transition name to
4986        its new name in cases where transitions get renamed; this will
4987        be empty when no re-naming occurs, including when
4988        `errorOnNameColision` is True. If there were any transitions
4989        connecting the nodes that were merged, these become self-edges
4990        of the merged node (and may be renamed if necessary).
4991        Note that all renamed transitions were originally based on the
4992        first (merged) node, since transitions of the second (merge
4993        target) node are not renamed.
4994
4995        ## Example
4996
4997        >>> g = DecisionGraph()
4998        >>> for fr, to, nm in [
4999        ...     ('A', 'B', 'up'),
5000        ...     ('A', 'B', 'up2'),
5001        ...     ('B', 'A', 'down'),
5002        ...     ('B', 'B', 'self'),
5003        ...     ('B', 'C', 'next'),
5004        ...     ('C', 'B', 'prev'),
5005        ...     ('A', 'C', 'right')
5006        ... ]:
5007        ...     if g.getDecision(fr) is None:
5008        ...        g.addDecision(fr)
5009        ...     if g.getDecision(to) is None:
5010        ...         g.addDecision(to)
5011        ...     g.addTransition(fr, nm, to)
5012        0
5013        1
5014        2
5015        >>> g.getDestination('A', 'up')
5016        1
5017        >>> g.getDestination('B', 'down')
5018        0
5019        >>> sorted(g)
5020        [0, 1, 2]
5021        >>> g.setReciprocal('A', 'up', 'down')
5022        >>> g.setReciprocal('B', 'next', 'prev')
5023        >>> g.mergeDecisions('C', 'B')
5024        {}
5025        >>> g.destinationsFrom('A')
5026        {'up': 1, 'up2': 1, 'right': 1}
5027        >>> g.destinationsFrom('B')
5028        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
5029        >>> 'C' in g
5030        False
5031        >>> g.mergeDecisions('A', 'A') # does nothing
5032        {}
5033        >>> # Can't merge non-existent decision
5034        >>> g.mergeDecisions('A', 'Z')
5035        Traceback (most recent call last):
5036        ...
5037        exploration.core.MissingDecisionError...
5038        >>> g.mergeDecisions('Z', 'A')
5039        Traceback (most recent call last):
5040        ...
5041        exploration.core.MissingDecisionError...
5042        >>> # Can't merge decisions w/ shared edge names
5043        >>> g.addDecision('D')
5044        3
5045        >>> g.addTransition('D', 'next', 'A')
5046        >>> g.addTransition('A', 'prev', 'D')
5047        >>> g.setReciprocal('D', 'next', 'prev')
5048        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
5049        Traceback (most recent call last):
5050        ...
5051        exploration.core.TransitionCollisionError...
5052        >>> # Auto-rename colliding edges
5053        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
5054        {'next': 'next.1'}
5055        >>> g.destination('B', 'next') # merge target unchanged
5056        1
5057        >>> g.destination('B', 'next.1') # merged decision name changed
5058        0
5059        >>> g.destination('B', 'prev') # name unchanged (no collision)
5060        1
5061        >>> g.getReciprocal('B', 'next') # unchanged (from B)
5062        'prev'
5063        >>> g.getReciprocal('B', 'next.1') # from A
5064        'prev'
5065        >>> g.getReciprocal('A', 'prev') # from B
5066        'next.1'
5067
5068        ## Folding four nodes into a 2-node loop
5069
5070        >>> g = DecisionGraph()
5071        >>> g.addDecision('X')
5072        0
5073        >>> g.addDecision('Y')
5074        1
5075        >>> g.addTransition('X', 'next', 'Y', 'prev')
5076        >>> g.addDecision('preX')
5077        2
5078        >>> g.addDecision('postY')
5079        3
5080        >>> g.addTransition('preX', 'next', 'X', 'prev')
5081        >>> g.addTransition('Y', 'next', 'postY', 'prev')
5082        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
5083        {'next': 'next.1'}
5084        >>> g.destinationsFrom('X')
5085        {'next': 1, 'prev': 1}
5086        >>> g.destinationsFrom('Y')
5087        {'prev': 0, 'next': 3, 'next.1': 0}
5088        >>> 2 in g
5089        False
5090        >>> g.destinationsFrom('postY')
5091        {'prev': 1}
5092        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
5093        {'prev': 'prev.1'}
5094        >>> g.destinationsFrom('X')
5095        {'next': 1, 'prev': 1, 'prev.1': 1}
5096        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
5097        {'prev': 0, 'next.1': 0, 'next': 0}
5098        >>> 2 in g
5099        False
5100        >>> 3 in g
5101        False
5102        >>> # Reciprocals are tangled...
5103        >>> g.getReciprocal(0, 'prev')
5104        'next.1'
5105        >>> g.getReciprocal(0, 'prev.1')
5106        'next'
5107        >>> g.getReciprocal(1, 'next')
5108        'prev.1'
5109        >>> g.getReciprocal(1, 'next.1')
5110        'prev'
5111        >>> # Note: one merge cannot handle both extra transitions
5112        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
5113        >>> # (It would merge both edges but the result would retain
5114        >>> # 'next.1' instead of retaining 'next'.)
5115        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
5116        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
5117        >>> g.destinationsFrom('X')
5118        {'next': 1, 'prev': 1}
5119        >>> g.destinationsFrom('Y')
5120        {'prev': 0, 'next': 0}
5121        >>> # Reciprocals were salvaged in second merger
5122        >>> g.getReciprocal('X', 'prev')
5123        'next'
5124        >>> g.getReciprocal('Y', 'next')
5125        'prev'
5126
5127        ## Merging with tags/requirements/annotations/consequences
5128
5129        >>> g = DecisionGraph()
5130        >>> g.addDecision('X')
5131        0
5132        >>> g.addDecision('Y')
5133        1
5134        >>> g.addDecision('Z')
5135        2
5136        >>> g.addTransition('X', 'next', 'Y', 'prev')
5137        >>> g.addTransition('X', 'down', 'Z', 'up')
5138        >>> g.tagDecision('X', 'tag0', 1)
5139        >>> g.tagDecision('Y', 'tag1', 10)
5140        >>> g.tagDecision('Y', 'unconfirmed')
5141        >>> g.tagDecision('Z', 'tag1', 20)
5142        >>> g.tagDecision('Z', 'tag2', 30)
5143        >>> g.tagTransition('X', 'next', 'ttag1', 11)
5144        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
5145        >>> g.tagTransition('X', 'down', 'ttag3', 33)
5146        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
5147        >>> g.annotateDecision('Y', 'annotation 1')
5148        >>> g.annotateDecision('Z', 'annotation 2')
5149        >>> g.annotateDecision('Z', 'annotation 3')
5150        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
5151        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
5152        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
5153        >>> g.setTransitionRequirement(
5154        ...     'X',
5155        ...     'next',
5156        ...     base.ReqCapability('power')
5157        ... )
5158        >>> g.setTransitionRequirement(
5159        ...     'Y',
5160        ...     'prev',
5161        ...     base.ReqTokens('token', 1)
5162        ... )
5163        >>> g.setTransitionRequirement(
5164        ...     'X',
5165        ...     'down',
5166        ...     base.ReqCapability('power2')
5167        ... )
5168        >>> g.setTransitionRequirement(
5169        ...     'Z',
5170        ...     'up',
5171        ...     base.ReqTokens('token2', 2)
5172        ... )
5173        >>> g.setConsequence(
5174        ...     'Y',
5175        ...     'prev',
5176        ...     [base.effect(gain="power2")]
5177        ... )
5178        >>> g.mergeDecisions('Y', 'Z')
5179        {}
5180        >>> g.destination('X', 'next')
5181        2
5182        >>> g.destination('X', 'down')
5183        2
5184        >>> g.destination('Z', 'prev')
5185        0
5186        >>> g.destination('Z', 'up')
5187        0
5188        >>> g.decisionTags('X')
5189        {'tag0': 1}
5190        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
5191        {'tag1': 20, 'tag2': 30}
5192        >>> g.transitionTags('X', 'next')
5193        {'ttag1': 11}
5194        >>> g.transitionTags('X', 'down')
5195        {'ttag3': 33}
5196        >>> g.transitionTags('Z', 'prev')
5197        {'ttag2': 22}
5198        >>> g.transitionTags('Z', 'up')
5199        {'ttag4': 44}
5200        >>> g.decisionAnnotations('Z')
5201        ['annotation 2', 'annotation 3', 'annotation 1']
5202        >>> g.transitionAnnotations('Z', 'prev')
5203        ['trans annotation 1', 'trans annotation 2']
5204        >>> g.transitionAnnotations('Z', 'up')
5205        ['trans annotation 3']
5206        >>> g.getTransitionRequirement('X', 'next')
5207        ReqCapability('power')
5208        >>> g.getTransitionRequirement('Z', 'prev')
5209        ReqTokens('token', 1)
5210        >>> g.getTransitionRequirement('X', 'down')
5211        ReqCapability('power2')
5212        >>> g.getTransitionRequirement('Z', 'up')
5213        ReqTokens('token2', 2)
5214        >>> g.getConsequence('Z', 'prev') == [
5215        ...     {
5216        ...         'type': 'gain',
5217        ...         'applyTo': 'active',
5218        ...         'value': 'power2',
5219        ...         'charges': None,
5220        ...         'delay': None,
5221        ...         'hidden': False
5222        ...     }
5223        ... ]
5224        True
5225
5226        ## Merging into node without tags
5227
5228        >>> g = DecisionGraph()
5229        >>> g.addDecision('X')
5230        0
5231        >>> g.addDecision('Y')
5232        1
5233        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
5234        >>> g.tagDecision('Y', 'tag', 'value')
5235        >>> g.mergeDecisions('Y', 'X')
5236        {}
5237        >>> g.decisionTags('X')
5238        {'tag': 'value'}
5239        >>> 0 in g  # Second argument remains
5240        True
5241        >>> 1 in g  # First argument is deleted
5242        False
5243        """
5244        # Resolve IDs
5245        mergeID = self.resolveDecision(merge)
5246        mergeIntoID = self.resolveDecision(mergeInto)
5247
5248        # Create our result as an empty dictionary
5249        result: Dict[base.Transition, base.Transition] = {}
5250
5251        # Short-circuit if the two decisions are the same
5252        if mergeID == mergeIntoID:
5253            return result
5254
5255        # MissingDecisionErrors from here if either doesn't exist
5256        allNewOutgoing = set(self.destinationsFrom(mergeID))
5257        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
5258        # Find colliding transition names
5259        collisions = allNewOutgoing & allOldOutgoing
5260        if len(collisions) > 0 and errorOnNameColision:
5261            raise TransitionCollisionError(
5262                f"Cannot merge decision {self.identityOf(merge)} into"
5263                f" decision {self.identityOf(mergeInto)}: the decisions"
5264                f" share {len(collisions)} transition names:"
5265                f" {collisions}\n(Note that errorOnNameColision was set"
5266                f" to True, set it to False to allow the operation by"
5267                f" renaming half of those transitions.)"
5268            )
5269
5270        # Record zones that will have to change after the merge
5271        zoneParents = self.zoneParents(mergeID)
5272
5273        # First, swap all incoming edges, along with their reciprocals
5274        # This will include self-edges, which will be retargeted and
5275        # whose reciprocals will be rebased in the process, leading to
5276        # the possibility of a missing edge during the loop
5277        for source, incoming in self.allEdgesTo(mergeID):
5278            # Skip this edge if it was already swapped away because it's
5279            # a self-loop with a reciprocal whose reciprocal was
5280            # processed earlier in the loop
5281            if incoming not in self.destinationsFrom(source):
5282                continue
5283
5284            # Find corresponding outgoing edge
5285            outgoing = self.getReciprocal(source, incoming)
5286
5287            # Swap both edges to new destination
5288            newOutgoing = self.retargetTransition(
5289                source,
5290                incoming,
5291                mergeIntoID,
5292                swapReciprocal=True,
5293                errorOnNameColision=False # collisions were detected above
5294            )
5295            # Add to our result if the name of the reciprocal was
5296            # changed
5297            if (
5298                outgoing is not None
5299            and newOutgoing is not None
5300            and outgoing != newOutgoing
5301            ):
5302                result[outgoing] = newOutgoing
5303
5304        # Next, swap any remaining outgoing edges (which didn't have
5305        # reciprocals, or they'd already be swapped, unless they were
5306        # self-edges previously). Note that in this loop, there can't be
5307        # any self-edges remaining, although there might be connections
5308        # between the merging nodes that need to become self-edges
5309        # because they used to be a self-edge that was half-retargeted
5310        # by the previous loop.
5311        # Note: a copy is used here to avoid iterating over a changing
5312        # dictionary
5313        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
5314            newOutgoing = self.rebaseTransition(
5315                mergeID,
5316                stillOutgoing,
5317                mergeIntoID,
5318                swapReciprocal=True,
5319                errorOnNameColision=False # collisions were detected above
5320            )
5321            if stillOutgoing != newOutgoing:
5322                result[stillOutgoing] = newOutgoing
5323
5324        # At this point, there shouldn't be any remaining incoming or
5325        # outgoing edges!
5326        assert self.degree(mergeID) == 0
5327
5328        # Merge tags & annotations
5329        # Note that these operations affect the underlying graph
5330        destTags = self.decisionTags(mergeIntoID)
5331        destUnvisited = 'unconfirmed' in destTags
5332        sourceTags = self.decisionTags(mergeID)
5333        sourceUnvisited = 'unconfirmed' in sourceTags
5334        # Copy over only new tags, leaving existing tags alone
5335        for key in sourceTags:
5336            if key not in destTags:
5337                destTags[key] = sourceTags[key]
5338
5339        if int(destUnvisited) + int(sourceUnvisited) == 1:
5340            del destTags['unconfirmed']
5341
5342        self.decisionAnnotations(mergeIntoID).extend(
5343            self.decisionAnnotations(mergeID)
5344        )
5345
5346        # Transfer zones
5347        for zone in zoneParents:
5348            self.addDecisionToZone(mergeIntoID, zone)
5349
5350        # Delete the old node
5351        self.removeDecision(mergeID)
5352
5353        return result
5354
5355    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
5356        """
5357        Deletes the specified decision from the graph, updating
5358        attendant structures like zones. Note that the ID of the deleted
5359        node will NOT be reused, unless it's specifically provided to
5360        `addIdentifiedDecision`.
5361
5362        For example:
5363
5364        >>> dg = DecisionGraph()
5365        >>> dg.addDecision('A')
5366        0
5367        >>> dg.addDecision('B')
5368        1
5369        >>> list(dg)
5370        [0, 1]
5371        >>> 1 in dg
5372        True
5373        >>> 'B' in dg.nameLookup
5374        True
5375        >>> dg.removeDecision('B')
5376        >>> 1 in dg
5377        False
5378        >>> list(dg)
5379        [0]
5380        >>> 'B' in dg.nameLookup
5381        False
5382        >>> dg.addDecision('C')  # doesn't re-use ID
5383        2
5384        """
5385        dID = self.resolveDecision(decision)
5386
5387        # Remove the target from all zones:
5388        for zone in self.zones:
5389            self.removeDecisionFromZone(dID, zone)
5390
5391        # Remove the node but record the current name
5392        name = self.nodes[dID]['name']
5393        self.remove_node(dID)
5394
5395        # Clean up the nameLookup entry
5396        luInfo = self.nameLookup[name]
5397        luInfo.remove(dID)
5398        if len(luInfo) == 0:
5399            self.nameLookup.pop(name)
5400
5401        # TODO: Clean up edges?
5402
5403    def renameDecision(
5404        self,
5405        decision: base.AnyDecisionSpecifier,
5406        newName: base.DecisionName
5407    ):
5408        """
5409        Renames a decision. The decision retains its old ID.
5410
5411        Generates a `DecisionCollisionWarning` if a decision using the new
5412        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
5413
5414        Example:
5415
5416        >>> g = DecisionGraph()
5417        >>> g.addDecision('one')
5418        0
5419        >>> g.addDecision('three')
5420        1
5421        >>> g.addTransition('one', '>', 'three')
5422        >>> g.addTransition('three', '<', 'one')
5423        >>> g.tagDecision('three', 'hi')
5424        >>> g.annotateDecision('three', 'note')
5425        >>> g.destination('one', '>')
5426        1
5427        >>> g.destination('three', '<')
5428        0
5429        >>> g.renameDecision('three', 'two')
5430        >>> g.resolveDecision('one')
5431        0
5432        >>> g.resolveDecision('two')
5433        1
5434        >>> g.resolveDecision('three')
5435        Traceback (most recent call last):
5436        ...
5437        exploration.core.MissingDecisionError...
5438        >>> g.destination('one', '>')
5439        1
5440        >>> g.nameFor(1)
5441        'two'
5442        >>> g.getDecision('three') is None
5443        True
5444        >>> g.destination('two', '<')
5445        0
5446        >>> g.decisionTags('two')
5447        {'hi': 1}
5448        >>> g.decisionAnnotations('two')
5449        ['note']
5450        """
5451        dID = self.resolveDecision(decision)
5452
5453        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
5454            warnings.warn(
5455                (
5456                    f"Can't rename {self.identityOf(decision)} as"
5457                    f" {newName!r} because a decision with that name"
5458                    f" already exists."
5459                ),
5460                DecisionCollisionWarning
5461            )
5462
5463        # Update name in node
5464        oldName = self.nodes[dID]['name']
5465        self.nodes[dID]['name'] = newName
5466
5467        # Update nameLookup entries
5468        oldNL = self.nameLookup[oldName]
5469        oldNL.remove(dID)
5470        if len(oldNL) == 0:
5471            self.nameLookup.pop(oldName)
5472        self.nameLookup.setdefault(newName, []).append(dID)
5473
5474    def mergeTransitions(
5475        self,
5476        fromDecision: base.AnyDecisionSpecifier,
5477        merge: base.Transition,
5478        mergeInto: base.Transition,
5479        mergeReciprocal=True
5480    ) -> None:
5481        """
5482        Given a decision and two transitions that start at that decision,
5483        merges the first transition into the second transition, combining
5484        their transition properties (using `mergeProperties`) and
5485        deleting the first transition. By default any reciprocal of the
5486        first transition is also merged into the reciprocal of the
5487        second, although you can set `mergeReciprocal` to `False` to
5488        disable this in which case the old reciprocal will lose its
5489        reciprocal relationship, even if the transition that was merged
5490        into does not have a reciprocal.
5491
5492        If the two names provided are the same, nothing will happen.
5493
5494        If the two transitions do not share the same destination, they
5495        cannot be merged, and an `InvalidDestinationError` will result.
5496        Use `retargetTransition` beforehand to ensure that they do if you
5497        want to merge transitions with different destinations.
5498
5499        A `MissingDecisionError` or `MissingTransitionError` will result
5500        if the decision or either transition does not exist.
5501
5502        If merging reciprocal properties was requested and the first
5503        transition does not have a reciprocal, then no reciprocal
5504        properties change. However, if the second transition does not
5505        have a reciprocal and the first does, the first transition's
5506        reciprocal will be set to the reciprocal of the second
5507        transition, and that transition will not be deleted as usual.
5508
5509        ## Example
5510
5511        >>> g = DecisionGraph()
5512        >>> g.addDecision('A')
5513        0
5514        >>> g.addDecision('B')
5515        1
5516        >>> g.addTransition('A', 'up', 'B')
5517        >>> g.addTransition('B', 'down', 'A')
5518        >>> g.setReciprocal('A', 'up', 'down')
5519        >>> # Merging a transition with no reciprocal
5520        >>> g.addTransition('A', 'up2', 'B')
5521        >>> g.mergeTransitions('A', 'up2', 'up')
5522        >>> g.getDestination('A', 'up2') is None
5523        True
5524        >>> g.getDestination('A', 'up')
5525        1
5526        >>> # Merging a transition with a reciprocal & tags
5527        >>> g.addTransition('A', 'up2', 'B')
5528        >>> g.addTransition('B', 'down2', 'A')
5529        >>> g.setReciprocal('A', 'up2', 'down2')
5530        >>> g.tagTransition('A', 'up2', 'one')
5531        >>> g.tagTransition('B', 'down2', 'two')
5532        >>> g.mergeTransitions('B', 'down2', 'down')
5533        >>> g.getDestination('A', 'up2') is None
5534        True
5535        >>> g.getDestination('A', 'up')
5536        1
5537        >>> g.getDestination('B', 'down2') is None
5538        True
5539        >>> g.getDestination('B', 'down')
5540        0
5541        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
5542        >>> g.addTransition('A', 'up2', 'B')
5543        >>> g.setTransitionProperties(
5544        ...     'A',
5545        ...     'up2',
5546        ...     requirement=base.ReqCapability('dash')
5547        ... )
5548        >>> g.setTransitionProperties('A', 'up',
5549        ...     requirement=base.ReqCapability('slide'))
5550        >>> g.mergeTransitions('A', 'up2', 'up')
5551        >>> g.getDestination('A', 'up2') is None
5552        True
5553        >>> repr(g.getTransitionRequirement('A', 'up'))
5554        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
5555        >>> # Errors if destinations differ, or if something is missing
5556        >>> g.mergeTransitions('A', 'down', 'up')
5557        Traceback (most recent call last):
5558        ...
5559        exploration.core.MissingTransitionError...
5560        >>> g.mergeTransitions('Z', 'one', 'two')
5561        Traceback (most recent call last):
5562        ...
5563        exploration.core.MissingDecisionError...
5564        >>> g.addDecision('C')
5565        2
5566        >>> g.addTransition('A', 'down', 'C')
5567        >>> g.mergeTransitions('A', 'down', 'up')
5568        Traceback (most recent call last):
5569        ...
5570        exploration.core.InvalidDestinationError...
5571        >>> # Merging a reciprocal onto an edge that doesn't have one
5572        >>> g.addTransition('A', 'down2', 'C')
5573        >>> g.addTransition('C', 'up2', 'A')
5574        >>> g.setReciprocal('A', 'down2', 'up2')
5575        >>> g.tagTransition('C', 'up2', 'narrow')
5576        >>> g.getReciprocal('A', 'down') is None
5577        True
5578        >>> g.mergeTransitions('A', 'down2', 'down')
5579        >>> g.getDestination('A', 'down2') is None
5580        True
5581        >>> g.getDestination('A', 'down')
5582        2
5583        >>> g.getDestination('C', 'up2')
5584        0
5585        >>> g.getReciprocal('A', 'down')
5586        'up2'
5587        >>> g.getReciprocal('C', 'up2')
5588        'down'
5589        >>> g.transitionTags('C', 'up2')
5590        {'narrow': 1}
5591        >>> # Merging without a reciprocal
5592        >>> g.addTransition('C', 'up', 'A')
5593        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
5594        >>> g.getDestination('C', 'up2') is None
5595        True
5596        >>> g.getDestination('C', 'up')
5597        0
5598        >>> g.transitionTags('C', 'up') # tag gets merged
5599        {'narrow': 1}
5600        >>> g.getDestination('A', 'down')
5601        2
5602        >>> g.getReciprocal('A', 'down') is None
5603        True
5604        >>> g.getReciprocal('C', 'up') is None
5605        True
5606        >>> # Merging w/ normal reciprocals
5607        >>> g.addDecision('D')
5608        3
5609        >>> g.addDecision('E')
5610        4
5611        >>> g.addTransition('D', 'up', 'E', 'return')
5612        >>> g.addTransition('E', 'down', 'D')
5613        >>> g.mergeTransitions('E', 'return', 'down')
5614        >>> g.getDestination('D', 'up')
5615        4
5616        >>> g.getDestination('E', 'down')
5617        3
5618        >>> g.getDestination('E', 'return') is None
5619        True
5620        >>> g.getReciprocal('D', 'up')
5621        'down'
5622        >>> g.getReciprocal('E', 'down')
5623        'up'
5624        >>> # Merging w/ weird reciprocals
5625        >>> g.addTransition('E', 'return', 'D')
5626        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
5627        >>> g.getReciprocal('D', 'up')
5628        'down'
5629        >>> g.getReciprocal('E', 'down')
5630        'up'
5631        >>> g.getReciprocal('E', 'return') # shared
5632        'up'
5633        >>> g.mergeTransitions('E', 'return', 'down')
5634        >>> g.getDestination('D', 'up')
5635        4
5636        >>> g.getDestination('E', 'down')
5637        3
5638        >>> g.getDestination('E', 'return') is None
5639        True
5640        >>> g.getReciprocal('D', 'up')
5641        'down'
5642        >>> g.getReciprocal('E', 'down')
5643        'up'
5644        """
5645        fromID = self.resolveDecision(fromDecision)
5646
5647        # Short-circuit in the no-op case
5648        if merge == mergeInto:
5649            return
5650
5651        # These lines will raise a MissingDecisionError or
5652        # MissingTransitionError if needed
5653        dest1 = self.destination(fromID, merge)
5654        dest2 = self.destination(fromID, mergeInto)
5655
5656        if dest1 != dest2:
5657            raise InvalidDestinationError(
5658                f"Cannot merge transition {merge!r} into transition"
5659                f" {mergeInto!r} from decision"
5660                f" {self.identityOf(fromDecision)} because their"
5661                f" destinations are different ({self.identityOf(dest1)}"
5662                f" and {self.identityOf(dest2)}).\nNote: you can use"
5663                f" `retargetTransition` to change the destination of a"
5664                f" transition."
5665            )
5666
5667        # Find and the transition properties
5668        props1 = self.getTransitionProperties(fromID, merge)
5669        props2 = self.getTransitionProperties(fromID, mergeInto)
5670        merged = mergeProperties(props1, props2)
5671        # Note that this doesn't change the reciprocal:
5672        self.setTransitionProperties(fromID, mergeInto, **merged)
5673
5674        # Merge the reciprocal properties if requested
5675        # Get reciprocal to merge into
5676        reciprocal = self.getReciprocal(fromID, mergeInto)
5677        # Get reciprocal that needs cleaning up
5678        altReciprocal = self.getReciprocal(fromID, merge)
5679        # If the reciprocal to be merged actually already was the
5680        # reciprocal to merge into, there's nothing to do here
5681        if altReciprocal != reciprocal:
5682            if not mergeReciprocal:
5683                # In this case, we sever the reciprocal relationship if
5684                # there is a reciprocal
5685                if altReciprocal is not None:
5686                    self.setReciprocal(dest1, altReciprocal, None)
5687                    # By default setBoth takes care of the other half
5688            else:
5689                # In this case, we try to merge reciprocals
5690                # If altReciprocal is None, we don't need to do anything
5691                if altReciprocal is not None:
5692                    # Was there already a reciprocal or not?
5693                    if reciprocal is None:
5694                        # altReciprocal becomes the new reciprocal and is
5695                        # not deleted
5696                        self.setReciprocal(
5697                            fromID,
5698                            mergeInto,
5699                            altReciprocal
5700                        )
5701                    else:
5702                        # merge reciprocal properties
5703                        props1 = self.getTransitionProperties(
5704                            dest1,
5705                            altReciprocal
5706                        )
5707                        props2 = self.getTransitionProperties(
5708                            dest2,
5709                            reciprocal
5710                        )
5711                        merged = mergeProperties(props1, props2)
5712                        self.setTransitionProperties(
5713                            dest1,
5714                            reciprocal,
5715                            **merged
5716                        )
5717
5718                        # delete the old reciprocal transition
5719                        self.remove_edge(dest1, fromID, altReciprocal)
5720
5721        # Delete the old transition (reciprocal deletion/severance is
5722        # handled above if necessary)
5723        self.remove_edge(fromID, dest1, merge)
5724
5725    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
5726        """
5727        Returns `True` or `False` depending on whether or not the
5728        specified decision has been confirmed. Uses the presence or
5729        absence of the 'unconfirmed' tag to determine this.
5730
5731        Note: 'unconfirmed' is used instead of 'confirmed' so that large
5732        graphs with many confirmed nodes will be smaller when saved.
5733        """
5734        dID = self.resolveDecision(decision)
5735
5736        return 'unconfirmed' not in self.nodes[dID]['tags']
5737
5738    def replaceUnconfirmed(
5739        self,
5740        fromDecision: base.AnyDecisionSpecifier,
5741        transition: base.Transition,
5742        connectTo: Optional[base.AnyDecisionSpecifier] = None,
5743        reciprocal: Optional[base.Transition] = None,
5744        requirement: Optional[base.Requirement] = None,
5745        applyConsequence: Optional[base.Consequence] = None,
5746        placeInZone: Optional[base.Zone] = None,
5747        forceNew: bool = False,
5748        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
5749        annotations: Optional[List[base.Annotation]] = None,
5750        revRequires: Optional[base.Requirement] = None,
5751        revConsequence: Optional[base.Consequence] = None,
5752        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5753        revAnnotations: Optional[List[base.Annotation]] = None,
5754        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5755        decisionAnnotations: Optional[List[base.Annotation]] = None
5756    ) -> Tuple[
5757        Dict[base.Transition, base.Transition],
5758        Dict[base.Transition, base.Transition]
5759    ]:
5760        """
5761        Given a decision and an edge name in that decision, where the
5762        named edge leads to a decision with an unconfirmed exploration
5763        state (see `isConfirmed`), renames the unexplored decision on
5764        the other end of that edge using the given `connectTo` name, or
5765        if a decision using that name already exists, merges the
5766        unexplored decision into that decision. If `connectTo` is a
5767        `DecisionSpecifier` whose target doesn't exist, it will be
5768        treated as just a name, but if it's an ID and it doesn't exist,
5769        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
5770        a reciprocal edge will be added using that name connecting the
5771        `connectTo` decision back to the original decision. If this
5772        transition already exists, it must also point to a node which is
5773        also unexplored, and which will also be merged into the
5774        `fromDecision` node.
5775
5776        If `connectTo` is not given (or is set to `None` explicitly)
5777        then the name of the unexplored decision will not be changed,
5778        unless that name has the form `'_u.-n-'` where `-n-` is a positive
5779        integer (i.e., the form given to automatically-named unknown
5780        nodes). In that case, the name will be changed to `'_x.-n-'` using
5781        the same number, or a higher number if that name is already taken.
5782
5783        If the destination is being renamed or if the destination's
5784        exploration state counts as unexplored, the exploration state of
5785        the destination will be set to 'exploring'.
5786
5787        If a `placeInZone` is specified, the destination will be placed
5788        directly into that zone (even if it already existed and has zone
5789        information), and it will be removed from any other zones it had
5790        been a direct member of. If `placeInZone` is set to
5791        `base.DefaultZone`, then the destination will be placed into
5792        each zone which is a direct parent of the origin, but only if
5793        the destination is not an already-explored existing decision AND
5794        it is not already in any zones (in those cases no zone changes
5795        are made). This will also remove it from any previous zones it
5796        had been a part of. If `placeInZone` is left as `None` (the
5797        default) no zone changes are made.
5798
5799        If `placeInZone` is specified and that zone didn't already exist,
5800        it will be created as a new level-0 zone and will be added as a
5801        sub-zone of each zone that's a direct parent of any level-0 zone
5802        that the origin is a member of.
5803
5804        If `forceNew` is specified, then the destination will just be
5805        renamed, even if another decision with the same name already
5806        exists. It's an error to use `forceNew` with a decision ID as
5807        the destination.
5808
5809        Any additional edges pointing to or from the unknown node(s)
5810        being replaced will also be re-targeted at the now-discovered
5811        known destination(s) if necessary. These edges will retain their
5812        reciprocal names, or if this would cause a name clash, they will
5813        be renamed with a suffix (see `retargetTransition`).
5814
5815        The return value is a pair of dictionaries mapping old names to
5816        new ones that just includes the names which were changed. The
5817        first dictionary contains renamed transitions that are outgoing
5818        from the new destination node (which used to be outgoing from
5819        the unexplored node). The second dictionary contains renamed
5820        transitions that are outgoing from the source node (which used
5821        to be outgoing from the unexplored node attached to the
5822        reciprocal transition; if there was no reciprocal transition
5823        specified then this will always be an empty dictionary).
5824
5825        An `ExplorationStatusError` will be raised if the destination
5826        of the specified transition counts as visited (see
5827        `hasBeenVisited`). An `ExplorationStatusError` will also be
5828        raised if the `connectTo`'s `reciprocal` transition does not lead
5829        to an unconfirmed decision (it's okay if this second transition
5830        doesn't exist). A `TransitionCollisionError` will be raised if
5831        the unconfirmed destination decision already has an outgoing
5832        transition with the specified `reciprocal` which does not lead
5833        back to the `fromDecision`.
5834
5835        The transition properties (requirement, consequences, tags,
5836        and/or annotations) of the replaced transition will be copied
5837        over to the new transition. Transition properties from the
5838        reciprocal transition will also be copied for the newly created
5839        reciprocal edge. Properties for any additional edges to/from the
5840        unknown node will also be copied.
5841
5842        Also, any transition properties on existing forward or reciprocal
5843        edges from the destination node with the indicated reverse name
5844        will be merged with those from the target transition. Note that
5845        this merging process may introduce corruption of complex
5846        transition consequences. TODO: Fix that!
5847
5848        Any tags and annotations are added to copied tags/annotations,
5849        but specified requirements, and/or consequences will replace
5850        previous requirements/consequences, rather than being added to
5851        them.
5852
5853        ## Example
5854
5855        >>> g = DecisionGraph()
5856        >>> g.addDecision('A')
5857        0
5858        >>> g.addUnexploredEdge('A', 'up')
5859        1
5860        >>> g.destination('A', 'up')
5861        1
5862        >>> g.destination('_u.0', 'return')
5863        0
5864        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
5865        ({}, {})
5866        >>> g.destination('A', 'up')
5867        1
5868        >>> g.nameFor(1)
5869        'B'
5870        >>> g.destination('B', 'down')
5871        0
5872        >>> g.getDestination('B', 'return') is None
5873        True
5874        >>> '_u.0' in g.nameLookup
5875        False
5876        >>> g.getReciprocal('A', 'up')
5877        'down'
5878        >>> g.getReciprocal('B', 'down')
5879        'up'
5880        >>> # Two unexplored edges to the same node:
5881        >>> g.addDecision('C')
5882        2
5883        >>> g.addTransition('B', 'next', 'C')
5884        >>> g.addTransition('C', 'prev', 'B')
5885        >>> g.setReciprocal('B', 'next', 'prev')
5886        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
5887        3
5888        >>> g.addTransition('C', 'down', 'D')
5889        >>> g.addTransition('D', 'up', 'C')
5890        >>> g.setReciprocal('C', 'down', 'up')
5891        >>> g.replaceUnconfirmed('C', 'down')
5892        ({}, {})
5893        >>> g.destination('C', 'down')
5894        3
5895        >>> g.destination('A', 'next')
5896        3
5897        >>> g.destinationsFrom('D')
5898        {'prev': 0, 'up': 2}
5899        >>> g.decisionTags('D')
5900        {}
5901        >>> # An unexplored transition which turns out to connect to a
5902        >>> # known decision, with name collisions
5903        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
5904        4
5905        >>> g.tagDecision('_u.2', 'wet')
5906        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
5907        Traceback (most recent call last):
5908        ...
5909        exploration.core.TransitionCollisionError...
5910        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
5911        5
5912        >>> g.tagDecision('_u.3', 'dry')
5913        >>> # Add transitions that will collide when merged
5914        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
5915        6
5916        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
5917        7
5918        >>> g.getReciprocal('A', 'prev')
5919        'next'
5920        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
5921        ({'prev': 'prev.1'}, {'up': 'up.1'})
5922        >>> g.destination('A', 'prev')
5923        3
5924        >>> g.destination('D', 'next')
5925        0
5926        >>> g.getReciprocal('A', 'prev')
5927        'next'
5928        >>> g.getReciprocal('D', 'next')
5929        'prev'
5930        >>> # Note that further unexplored structures are NOT merged
5931        >>> # even if they match against existing structures...
5932        >>> g.destination('A', 'up.1')
5933        6
5934        >>> g.destination('D', 'prev.1')
5935        7
5936        >>> '_u.2' in g.nameLookup
5937        False
5938        >>> '_u.3' in g.nameLookup
5939        False
5940        >>> g.decisionTags('D') # tags are merged
5941        {'dry': 1}
5942        >>> g.decisionTags('A')
5943        {'wet': 1}
5944        >>> # Auto-renaming an anonymous unexplored node
5945        >>> g.addUnexploredEdge('B', 'out')
5946        8
5947        >>> g.replaceUnconfirmed('B', 'out')
5948        ({}, {})
5949        >>> '_u.6' in g
5950        False
5951        >>> g.destination('B', 'out')
5952        8
5953        >>> g.nameFor(8)
5954        '_x.6'
5955        >>> g.destination('_x.6', 'return')
5956        1
5957        >>> # Placing a node into a zone
5958        >>> g.addUnexploredEdge('B', 'through')
5959        9
5960        >>> g.getDecision('E') is None
5961        True
5962        >>> g.replaceUnconfirmed(
5963        ...     'B',
5964        ...     'through',
5965        ...     'E',
5966        ...     'back',
5967        ...     placeInZone='Zone'
5968        ... )
5969        ({}, {})
5970        >>> g.getDecision('E')
5971        9
5972        >>> g.destination('B', 'through')
5973        9
5974        >>> g.destination('E', 'back')
5975        1
5976        >>> g.zoneParents(9)
5977        {'Zone'}
5978        >>> g.addUnexploredEdge('E', 'farther')
5979        10
5980        >>> g.replaceUnconfirmed(
5981        ...     'E',
5982        ...     'farther',
5983        ...     'F',
5984        ...     'closer',
5985        ...     placeInZone=base.DefaultZone
5986        ... )
5987        ({}, {})
5988        >>> g.destination('E', 'farther')
5989        10
5990        >>> g.destination('F', 'closer')
5991        9
5992        >>> g.zoneParents(10)
5993        {'Zone'}
5994        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
5995        11
5996        >>> g.replaceUnconfirmed(
5997        ...     'F',
5998        ...     'backwards',
5999        ...     'G',
6000        ...     'forwards',
6001        ...     placeInZone=base.DefaultZone
6002        ... )
6003        ({}, {})
6004        >>> g.destination('F', 'backwards')
6005        11
6006        >>> g.destination('G', 'forwards')
6007        10
6008        >>> g.zoneParents(11)  # not changed since it already had a zone
6009        {'Enoz'}
6010        >>> # TODO: forceNew example
6011        """
6012
6013        # Defaults
6014        if tags is None:
6015            tags = {}
6016        if annotations is None:
6017            annotations = []
6018        if revTags is None:
6019            revTags = {}
6020        if revAnnotations is None:
6021            revAnnotations = []
6022        if decisionTags is None:
6023            decisionTags = {}
6024        if decisionAnnotations is None:
6025            decisionAnnotations = []
6026
6027        # Resolve source
6028        fromID = self.resolveDecision(fromDecision)
6029
6030        # Figure out destination decision
6031        oldUnexplored = self.destination(fromID, transition)
6032        if self.isConfirmed(oldUnexplored):
6033            raise ExplorationStatusError(
6034                f"Transition {transition!r} from"
6035                f" {self.identityOf(fromDecision)} does not lead to an"
6036                f" unconfirmed decision (it leads to"
6037                f" {self.identityOf(oldUnexplored)} which is not tagged"
6038                f" 'unconfirmed')."
6039            )
6040
6041        # Resolve destination
6042        newName: Optional[base.DecisionName] = None
6043        connectID: Optional[base.DecisionID] = None
6044        if forceNew:
6045            if isinstance(connectTo, base.DecisionID):
6046                raise TypeError(
6047                    f"connectTo cannot be a decision ID when forceNew"
6048                    f" is True. Got: {self.identityOf(connectTo)}"
6049                )
6050            elif isinstance(connectTo, base.DecisionSpecifier):
6051                newName = connectTo.name
6052            elif isinstance(connectTo, base.DecisionName):
6053                newName = connectTo
6054            elif connectTo is None:
6055                oldName = self.nameFor(oldUnexplored)
6056                if (
6057                    oldName.startswith('_u.')
6058                and oldName[3:].isdigit()
6059                ):
6060                    newName = utils.uniqueName('_x.' + oldName[3:], self)
6061                else:
6062                    newName = oldName
6063            else:
6064                raise TypeError(
6065                    f"Invalid connectTo value: {connectTo!r}"
6066                )
6067        elif connectTo is not None:
6068            try:
6069                connectID = self.resolveDecision(connectTo)
6070                # leave newName as None
6071            except MissingDecisionError:
6072                if isinstance(connectTo, int):
6073                    raise
6074                elif isinstance(connectTo, base.DecisionSpecifier):
6075                    newName = connectTo.name
6076                    # The domain & zone are ignored here
6077                else:  # Must just be a string
6078                    assert isinstance(connectTo, str)
6079                    newName = connectTo
6080        else:
6081            # If connectTo name wasn't specified, use current name of
6082            # unknown node unless it's a default name
6083            oldName = self.nameFor(oldUnexplored)
6084            if (
6085                oldName.startswith('_u.')
6086            and oldName[3:].isdigit()
6087            ):
6088                newName = utils.uniqueName('_x.' + oldName[3:], self)
6089            else:
6090                newName = oldName
6091
6092        # One or the other should be valid at this point
6093        assert connectID is not None or newName is not None
6094
6095        # Check that the old unknown doesn't have a reciprocal edge that
6096        # would collide with the specified return edge
6097        if reciprocal is not None:
6098            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
6099            if revFromUnknown not in (None, fromID):
6100                raise TransitionCollisionError(
6101                    f"Transition {reciprocal!r} from"
6102                    f" {self.identityOf(oldUnexplored)} exists and does"
6103                    f" not lead back to {self.identityOf(fromDecision)}"
6104                    f" (it leads to {self.identityOf(revFromUnknown)})."
6105                )
6106
6107        # Remember old reciprocal edge for future merging in case
6108        # it's not reciprocal
6109        oldReciprocal = self.getReciprocal(fromID, transition)
6110
6111        # Apply any new tags or annotations, or create a new node
6112        needsZoneInfo = False
6113        if connectID is not None:
6114            # Before applying tags, check if we need to error out
6115            # because of a reciprocal edge that points to a known
6116            # destination:
6117            if reciprocal is not None:
6118                otherOldUnknown: Optional[
6119                    base.DecisionID
6120                ] = self.getDestination(
6121                    connectID,
6122                    reciprocal
6123                )
6124                if (
6125                    otherOldUnknown is not None
6126                and self.isConfirmed(otherOldUnknown)
6127                ):
6128                    raise ExplorationStatusError(
6129                        f"Reciprocal transition {reciprocal!r} from"
6130                        f" {self.identityOf(connectTo)} does not lead"
6131                        f" to an unconfirmed decision (it leads to"
6132                        f" {self.identityOf(otherOldUnknown)})."
6133                    )
6134            self.tagDecision(connectID, decisionTags)
6135            self.annotateDecision(connectID, decisionAnnotations)
6136            # Still needs zone info if the place we're connecting to was
6137            # unconfirmed up until now, since unconfirmed nodes don't
6138            # normally get zone info when they're created.
6139            if not self.isConfirmed(connectID):
6140                needsZoneInfo = True
6141
6142            # First, merge the old unknown with the connectTo node...
6143            destRenames = self.mergeDecisions(
6144                oldUnexplored,
6145                connectID,
6146                errorOnNameColision=False
6147            )
6148        else:
6149            needsZoneInfo = True
6150            if len(self.zoneParents(oldUnexplored)) > 0:
6151                needsZoneInfo = False
6152            assert newName is not None
6153            self.renameDecision(oldUnexplored, newName)
6154            connectID = oldUnexplored
6155            # In this case there can't be an other old unknown
6156            otherOldUnknown = None
6157            destRenames = {}  # empty
6158
6159        # Check for domain mismatch to stifle zone updates:
6160        fromDomain = self.domainFor(fromID)
6161        if connectID is None:
6162            destDomain = self.domainFor(oldUnexplored)
6163        else:
6164            destDomain = self.domainFor(connectID)
6165
6166        # Stifle zone updates if there's a mismatch
6167        if fromDomain != destDomain:
6168            needsZoneInfo = False
6169
6170        # Records renames that happen at the source (from node)
6171        sourceRenames = {}  # empty for now
6172
6173        assert connectID is not None
6174
6175        # Apply the new zone if there is one
6176        if placeInZone is not None:
6177            if placeInZone == base.DefaultZone:
6178                # When using DefaultZone, changes are only made for new
6179                # destinations which don't already have any zones and
6180                # which are in the same domain as the departing node:
6181                # they get placed into each zone parent of the source
6182                # decision.
6183                if needsZoneInfo:
6184                    # Remove destination from all current parents
6185                    removeFrom = set(self.zoneParents(connectID))  # copy
6186                    for parent in removeFrom:
6187                        self.removeDecisionFromZone(connectID, parent)
6188                    # Add it to parents of origin
6189                    for parent in self.zoneParents(fromID):
6190                        self.addDecisionToZone(connectID, parent)
6191            else:
6192                placeInZone = cast(base.Zone, placeInZone)
6193                # Create the zone if it doesn't already exist
6194                if self.getZoneInfo(placeInZone) is None:
6195                    self.createZone(placeInZone, 0)
6196                    # Add it to each grandparent of the from decision
6197                    for parent in self.zoneParents(fromID):
6198                        for grandparent in self.zoneParents(parent):
6199                            self.addZoneToZone(placeInZone, grandparent)
6200                # Remove destination from all current parents
6201                for parent in set(self.zoneParents(connectID)):
6202                    self.removeDecisionFromZone(connectID, parent)
6203                # Add it to the specified zone
6204                self.addDecisionToZone(connectID, placeInZone)
6205
6206        # Next, if there is a reciprocal name specified, we do more...
6207        if reciprocal is not None:
6208            # Figure out what kind of merging needs to happen
6209            if otherOldUnknown is None:
6210                if revFromUnknown is None:
6211                    # Just create the desired reciprocal transition, which
6212                    # we know does not already exist
6213                    self.addTransition(connectID, reciprocal, fromID)
6214                    otherOldReciprocal = None
6215                else:
6216                    # Reciprocal exists, as revFromUnknown
6217                    otherOldReciprocal = None
6218            else:
6219                otherOldReciprocal = self.getReciprocal(
6220                    connectID,
6221                    reciprocal
6222                )
6223                # we need to merge otherOldUnknown into our fromDecision
6224                sourceRenames = self.mergeDecisions(
6225                    otherOldUnknown,
6226                    fromID,
6227                    errorOnNameColision=False
6228                )
6229                # Unvisited tag after merge only if both were
6230
6231            # No matter what happened we ensure the reciprocal
6232            # relationship is set up:
6233            self.setReciprocal(fromID, transition, reciprocal)
6234
6235            # Now we might need to merge some transitions:
6236            # - Any reciprocal of the target transition should be merged
6237            #   with reciprocal (if it was already reciprocal, that's a
6238            #   no-op).
6239            # - Any reciprocal of the reciprocal transition from the target
6240            #   node (leading to otherOldUnknown) should be merged with
6241            #   the target transition, even if it shared a name and was
6242            #   renamed as a result.
6243            # - If reciprocal was renamed during the initial merge, those
6244            #   transitions should be merged.
6245
6246            # Merge old reciprocal into reciprocal
6247            if oldReciprocal is not None:
6248                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
6249                if self.getDestination(connectID, oldRev) is not None:
6250                    # Note that we don't want to auto-merge the reciprocal,
6251                    # which is the target transition
6252                    self.mergeTransitions(
6253                        connectID,
6254                        oldRev,
6255                        reciprocal,
6256                        mergeReciprocal=False
6257                    )
6258                    # Remove it from the renames map
6259                    if oldReciprocal in destRenames:
6260                        del destRenames[oldReciprocal]
6261
6262            # Merge reciprocal reciprocal from otherOldUnknown
6263            if otherOldReciprocal is not None:
6264                otherOldRev = sourceRenames.get(
6265                    otherOldReciprocal,
6266                    otherOldReciprocal
6267                )
6268                # Note that the reciprocal is reciprocal, which we don't
6269                # need to merge
6270                self.mergeTransitions(
6271                    fromID,
6272                    otherOldRev,
6273                    transition,
6274                    mergeReciprocal=False
6275                )
6276                # Remove it from the renames map
6277                if otherOldReciprocal in sourceRenames:
6278                    del sourceRenames[otherOldReciprocal]
6279
6280            # Merge any renamed reciprocal onto reciprocal
6281            if reciprocal in destRenames:
6282                extraRev = destRenames[reciprocal]
6283                self.mergeTransitions(
6284                    connectID,
6285                    extraRev,
6286                    reciprocal,
6287                    mergeReciprocal=False
6288                )
6289                # Remove it from the renames map
6290                del destRenames[reciprocal]
6291
6292        # Accumulate new tags & annotations for the transitions
6293        self.tagTransition(fromID, transition, tags)
6294        self.annotateTransition(fromID, transition, annotations)
6295
6296        if reciprocal is not None:
6297            self.tagTransition(connectID, reciprocal, revTags)
6298            self.annotateTransition(connectID, reciprocal, revAnnotations)
6299
6300        # Override copied requirement/consequences for the transitions
6301        if requirement is not None:
6302            self.setTransitionRequirement(
6303                fromID,
6304                transition,
6305                requirement
6306            )
6307        if applyConsequence is not None:
6308            self.setConsequence(
6309                fromID,
6310                transition,
6311                applyConsequence
6312            )
6313
6314        if reciprocal is not None:
6315            if revRequires is not None:
6316                self.setTransitionRequirement(
6317                    connectID,
6318                    reciprocal,
6319                    revRequires
6320                )
6321            if revConsequence is not None:
6322                self.setConsequence(
6323                    connectID,
6324                    reciprocal,
6325                    revConsequence
6326                )
6327
6328        # Remove 'unconfirmed' tag if it was present
6329        self.untagDecision(connectID, 'unconfirmed')
6330
6331        # Final checks
6332        assert self.getDestination(fromDecision, transition) == connectID
6333        useConnect: base.AnyDecisionSpecifier
6334        useRev: Optional[str]
6335        if connectTo is None:
6336            useConnect = connectID
6337        else:
6338            useConnect = connectTo
6339        if reciprocal is None:
6340            useRev = self.getReciprocal(fromDecision, transition)
6341        else:
6342            useRev = reciprocal
6343        if useRev is not None:
6344            try:
6345                assert self.getDestination(useConnect, useRev) == fromID
6346            except AmbiguousDecisionSpecifierError:
6347                assert self.getDestination(connectID, useRev) == fromID
6348
6349        # Return our final rename dictionaries
6350        return (destRenames, sourceRenames)
6351
6352    def endingID(self, name: base.DecisionName) -> base.DecisionID:
6353        """
6354        Returns the decision ID for the ending with the specified name.
6355        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
6356        don't normally include any zone information. If no ending with
6357        the specified name already existed, then a new ending with that
6358        name will be created and its Decision ID will be returned.
6359
6360        If a new decision is created, it will be tagged as unconfirmed.
6361
6362        Note that endings mostly aren't special: they're normal
6363        decisions in a separate singular-focalized domain. However, some
6364        parts of the exploration and journal machinery treat them
6365        differently (in particular, taking certain actions via
6366        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
6367        active is an error.
6368        """
6369        # Create our new ending decision if we need to
6370        try:
6371            endID = self.resolveDecision(
6372                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
6373            )
6374        except MissingDecisionError:
6375            # Create a new decision for the ending
6376            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
6377            # Tag it as unconfirmed
6378            self.tagDecision(endID, 'unconfirmed')
6379
6380        return endID
6381
6382    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
6383        """
6384        Given the name of a trigger group, returns the ID of the special
6385        node representing that trigger group in the `TRIGGERS_DOMAIN`.
6386        If the specified group didn't already exist, it will be created.
6387
6388        Trigger group decisions are not special: they just exist in a
6389        separate spreading-focalized domain and have a few API methods to
6390        access them, but all the normal decision-related API methods
6391        still work. Their intended use is for sets of global triggers,
6392        by attaching actions with the 'trigger' tag to them and then
6393        activating or deactivating them as needed.
6394        """
6395        result = self.getDecision(
6396            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
6397        )
6398        if result is None:
6399            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
6400        else:
6401            return result
6402
6403    @staticmethod
6404    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
6405        """
6406        Returns one of a number of example decision graphs, depending on
6407        the string given. It returns a fresh copy each time. The graphs
6408        are:
6409
6410        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
6411            and 2, each connected to the next in the sequence by a
6412            'next' transition with reciprocal 'prev'. In other words, a
6413            simple little triangle. There are no tags, annotations,
6414            requirements, consequences, mechanisms, or equivalences.
6415        - 'abc': A more complicated 3-node setup that introduces a
6416            little bit of everything. In this graph, we have the same
6417            three nodes, but different transitions:
6418
6419                * From A you can go 'left' to B with reciprocal 'right'.
6420                * From A you can also go 'up_left' to B with reciprocal
6421                    'up_right'. These transitions both require the
6422                    'grate' mechanism (which is at decision A) to be in
6423                    state 'open'.
6424                * From A you can go 'down' to C with reciprocal 'up'.
6425
6426            (In this graph, B and C are not directly connected to each
6427            other.)
6428
6429            The graph has two level-0 zones 'zoneA' and 'zoneB', along
6430            with a level-1 zone 'upZone'. Decisions A and C are in
6431            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
6432            not.
6433
6434            The decision A has annotation:
6435
6436                'This is a multi-word "annotation."'
6437
6438            The transition 'down' from A has annotation:
6439
6440                "Transition 'annotation.'"
6441
6442            Decision B has tags 'b' with value 1 and 'tag2' with value
6443            '"value"'.
6444
6445            Decision C has tag 'aw"ful' with value "ha'ha'".
6446
6447            Transition 'up' from C has tag 'fast' with value 1.
6448
6449            At decision C there are actions 'grab_helmet' and
6450            'pull_lever'.
6451
6452            The 'grab_helmet' transition requires that you don't have
6453            the 'helmet' capability, and gives you that capability,
6454            deactivating with delay 3.
6455
6456            The 'pull_lever' transition requires that you do have the
6457            'helmet' capability, and takes away that capability, but it
6458            also gives you 1 token, and if you have 2 tokens (before
6459            getting the one extra), it sets the 'grate' mechanism (which
6460            is a decision A) to state 'open' and deactivates.
6461
6462            The graph has an equivalence: having the 'helmet' capability
6463            satisfies requirements for the 'grate' mechanism to be in the
6464            'open' state.
6465
6466        """
6467        result = DecisionGraph()
6468        if which == 'simple':
6469            result.addDecision('A')  # id 0
6470            result.addDecision('B')  # id 1
6471            result.addDecision('C')  # id 2
6472            result.addTransition('A', 'next', 'B', 'prev')
6473            result.addTransition('B', 'next', 'C', 'prev')
6474            result.addTransition('C', 'next', 'A', 'prev')
6475        elif which == 'abc':
6476            result.addDecision('A')  # id 0
6477            result.addDecision('B')  # id 1
6478            result.addDecision('C')  # id 2
6479            result.createZone('zoneA', 0)
6480            result.createZone('zoneB', 0)
6481            result.createZone('upZone', 1)
6482            result.addZoneToZone('zoneA', 'upZone')
6483            result.addDecisionToZone('A', 'zoneA')
6484            result.addDecisionToZone('B', 'zoneB')
6485            result.addDecisionToZone('C', 'zoneA')
6486            result.addTransition('A', 'left', 'B', 'right')
6487            result.addTransition('A', 'up_left', 'B', 'up_right')
6488            result.addTransition('A', 'down', 'C', 'up')
6489            result.setTransitionRequirement(
6490                'A',
6491                'up_left',
6492                base.ReqMechanism('grate', 'open')
6493            )
6494            result.setTransitionRequirement(
6495                'B',
6496                'up_right',
6497                base.ReqMechanism('grate', 'open')
6498            )
6499            result.annotateDecision('A', 'This is a multi-word "annotation."')
6500            result.annotateTransition('A', 'down', "Transition 'annotation.'")
6501            result.tagDecision('B', 'b')
6502            result.tagDecision('B', 'tag2', '"value"')
6503            result.tagDecision('C', 'aw"ful', "ha'ha")
6504            result.tagTransition('C', 'up', 'fast')
6505            result.addMechanism('grate', 'A')
6506            result.addAction(
6507                'C',
6508                'grab_helmet',
6509                base.ReqNot(base.ReqCapability('helmet')),
6510                [
6511                    base.effect(gain='helmet'),
6512                    base.effect(deactivate=True, delay=3)
6513                ]
6514            )
6515            result.addAction(
6516                'C',
6517                'pull_lever',
6518                base.ReqCapability('helmet'),
6519                [
6520                    base.effect(lose='helmet'),
6521                    base.effect(gain=('token', 1)),
6522                    base.condition(
6523                        base.ReqTokens('token', 2),
6524                        [
6525                            base.effect(set=('grate', 'open')),
6526                            base.effect(deactivate=True)
6527                        ]
6528                    )
6529                ]
6530            )
6531            result.addEquivalence(
6532                base.ReqCapability('helmet'),
6533                (0, 'open')
6534            )
6535        else:
6536            raise ValueError(f"Invalid example name: {which!r}")
6537
6538        return result

Represents a view of the world as a topological graph at a moment in time. It derives from networkx.MultiDiGraph.

Each node (a Decision) represents a place in the world where there are multiple opportunities for travel/action, or a dead end where you must turn around and go back; typically this is a single room in a game, but sometimes one room has multiple decision points. Edges (Transitions) represent choices that can be made to travel to other decision points (e.g., taking the left door), or when they are self-edges, they represent actions that can be taken within a location that affect the world or the game state.

Each Transition includes a Effects dictionary indicating the effects that it has. Other effects of the transition that are not simple enough to be included in this format may be represented in an DiscreteExploration by changing the graph in the next step to reflect further effects of a transition.

In addition to normal transitions between decisions, a DecisionGraph can represent potential transitions which lead to unknown destinations. These are represented by adding decisions with the 'unconfirmed' tag (whose names where not specified begin with '_u.') with a separate unconfirmed decision for each transition (although where it's known that two transitions lead to the same unconfirmed decision, this can be represented as well).

Both nodes and edges can have Annotations associated with them that include extra details about the explorer's perception of the situation. They can also have Tags, which represent specific categories a transition or decision falls into.

Nodes can also be part of one or more Zones, and zones can also be part of other zones, allowing for a hierarchical description of the underlying space.

Equivalences can be specified to mark that some combination of capabilities can stand in for another capability.

DecisionGraph()
420    def __init__(self) -> None:
421        super().__init__()
422
423        self.zones: Dict[base.Zone, base.ZoneInfo] = {}
424        """
425        Mapping from zone names to zone info
426        """
427
428        self.unknownCount: int = 0
429        """
430        Number of unknown decisions that have been created (not number
431        of current unknown decisions, which is likely lower)
432        """
433
434        self.equivalences: base.Equivalences = {}
435        """
436        See `base.Equivalences`. Determines what capabilities and/or
437        mechanism states can count as active based on alternate
438        requirements.
439        """
440
441        self.reversionTypes: Dict[str, Set[str]] = {}
442        """
443        This tracks shorthand reversion types. See `base.revertedState`
444        for how these are applied. Keys are custom names and values are
445        reversion type strings that `base.revertedState` could access.
446        """
447
448        self.nextID: base.DecisionID = 0
449        """
450        The ID to use for the next new decision we create.
451        """
452
453        self.nextMechanismID: base.MechanismID = 0
454        """
455        ID for the next mechanism.
456        """
457
458        self.mechanisms: Dict[
459            base.MechanismID,
460            Tuple[Optional[base.DecisionID], base.MechanismName]
461        ] = {}
462        """
463        Mapping from `MechanismID`s to (`DecisionID`, `MechanismName`)
464        pairs. For global mechanisms, the `DecisionID` is None.
465        """
466
467        self.globalMechanisms: Dict[
468            base.MechanismName,
469            base.MechanismID
470        ] = {}
471        """
472        Global mechanisms
473        """
474
475        self.nameLookup: Dict[base.DecisionName, List[base.DecisionID]] = {}
476        """
477        A cache for name -> ID lookups
478        """

Initialize a graph with edges, name, or graph attributes.

Parameters

incoming_graph_data : input graph Data to initialize graph. If incoming_graph_data=None (default) an empty graph is created. The data can be an edge list, or any NetworkX graph object. If the corresponding optional Python packages are installed the data can also be a 2D NumPy array, a SciPy sparse array, or a PyGraphviz graph.

multigraph_input : bool or None (default None) Note: Only used when incoming_graph_data is a dict. If True, incoming_graph_data is assumed to be a dict-of-dict-of-dict-of-dict structure keyed by node to neighbor to edge keys to edge data for multi-edges. A NetworkXError is raised if this is not the case. If False, to_networkx_graph() is used to try to determine the dict's graph data structure as either a dict-of-dict-of-dict keyed by node to neighbor to edge data, or a dict-of-iterable keyed by node to neighbors. If None, the treatment for True is tried, but if it fails, the treatment for False is tried.

attr : keyword arguments, optional (default= no attributes) Attributes to add to graph as key=value pairs.

See Also

convert

Examples

>>> G = nx.Graph()  # or DiGraph, MultiGraph, MultiDiGraph, etc
>>> G = nx.Graph(name="my graph")
>>> e = [(1, 2), (2, 3), (3, 4)]  # list of edges
>>> G = nx.Graph(e)

Arbitrary graph attribute pairs (key=value) may be assigned

>>> G = nx.Graph(e, day="Friday")
>>> G.graph
{'day': 'Friday'}
zones: Dict[str, exploration.base.ZoneInfo]

Mapping from zone names to zone info

unknownCount: int

Number of unknown decisions that have been created (not number of current unknown decisions, which is likely lower)

equivalences: Dict[Union[str, Tuple[int, str]], Set[exploration.base.Requirement]]

See base.Equivalences. Determines what capabilities and/or mechanism states can count as active based on alternate requirements.

reversionTypes: Dict[str, Set[str]]

This tracks shorthand reversion types. See base.revertedState for how these are applied. Keys are custom names and values are reversion type strings that base.revertedState could access.

nextID: int

The ID to use for the next new decision we create.

nextMechanismID: int

ID for the next mechanism.

mechanisms: Dict[int, Tuple[Optional[int], str]]

Mapping from MechanismIDs to (DecisionID, MechanismName) pairs. For global mechanisms, the DecisionID is None.

globalMechanisms: Dict[str, int]

Global mechanisms

nameLookup: Dict[str, List[int]]

A cache for name -> ID lookups

def listDifferences( self, other: DecisionGraph) -> Generator[str, NoneType, NoneType]:
526    def listDifferences(
527        self,
528        other: 'DecisionGraph'
529    ) -> Generator[str, None, None]:
530        """
531        Generates strings describing differences between this graph and
532        another graph. This does NOT perform graph matching, so it will
533        consider graphs different even if they have identical structures
534        but use different IDs for the nodes in those structures.
535        """
536        if not isinstance(other, DecisionGraph):
537            yield "other is not a graph"
538        else:
539            suppress = False
540            myNodes = set(self.nodes)
541            theirNodes = set(other.nodes)
542            for n in myNodes:
543                if n not in theirNodes:
544                    suppress = True
545                    yield (
546                        f"other graph missing node {n}"
547                    )
548                else:
549                    if self.nodes[n] != other.nodes[n]:
550                        suppress = True
551                        yield (
552                            f"other graph has differences at node {n}:"
553                            f"\n  Ours:  {self.nodes[n]}"
554                            f"\nTheirs:  {other.nodes[n]}"
555                        )
556                    myDests = self.destinationsFrom(n)
557                    theirDests = other.destinationsFrom(n)
558                    for tr in myDests:
559                        myTo = myDests[tr]
560                        if tr not in theirDests:
561                            suppress = True
562                            yield (
563                                f"at {self.identityOf(n)}: other graph"
564                                f" missing transition {tr!r}"
565                            )
566                        else:
567                            theirTo = theirDests[tr]
568                            if myTo != theirTo:
569                                suppress = True
570                                yield (
571                                    f"at {self.identityOf(n)}: other"
572                                    f" graph transition {tr!r} leads to"
573                                    f" {theirTo} instead of {myTo}"
574                                )
575                            else:
576                                myProps = self.edges[n, myTo, tr]  # type:ignore [index] # noqa
577                                theirProps = other.edges[n, myTo, tr]  # type:ignore [index] # noqa
578                                if myProps != theirProps:
579                                    suppress = True
580                                    yield (
581                                        f"at {self.identityOf(n)}: other"
582                                        f" graph transition {tr!r} has"
583                                        f" different properties:"
584                                        f"\n  Ours:  {myProps}"
585                                        f"\nTheirs:  {theirProps}"
586                                    )
587            for extra in theirNodes - myNodes:
588                suppress = True
589                yield (
590                    f"other graph has extra node {extra}"
591                )
592
593            # TODO: Fix networkx stubs!
594            if self.graph != other.graph:  # type:ignore [attr-defined]
595                suppress = True
596                yield (
597                    " different graph attributes:"  # type:ignore [attr-defined]  # noqa
598                    f"\n  Ours:  {self.graph}"
599                    f"\nTheirs:  {other.graph}"
600                )
601
602            # Checks any other graph data we might have missed
603            if not super().__eq__(other) and not suppress:
604                for attr in dir(self):
605                    if attr.startswith('__') and attr.endswith('__'):
606                        continue
607                    if not hasattr(other, attr):
608                        yield f"other graph missing attribute: {attr!r}"
609                    else:
610                        myVal = getattr(self, attr)
611                        theirVal = getattr(other, attr)
612                        if (
613                            myVal != theirVal
614                        and not ((callable(myVal) and callable(theirVal)))
615                        ):
616                            yield (
617                                f"other has different val for {attr!r}:"
618                                f"\n  Ours:  {myVal}"
619                                f"\nTheirs:  {theirVal}"
620                            )
621                for attr in sorted(set(dir(other)) - set(dir(self))):
622                    yield f"other has extra attribute: {attr!r}"
623                yield "graph data is different"
624                # TODO: More detail here!
625
626            # Check unknown count
627            if self.unknownCount != other.unknownCount:
628                yield "unknown count is different"
629
630            # Check zones
631            if self.zones != other.zones:
632                yield "zones are different"
633
634            # Check equivalences
635            if self.equivalences != other.equivalences:
636                yield "equivalences are different"
637
638            # Check reversion types
639            if self.reversionTypes != other.reversionTypes:
640                yield "reversionTypes are different"
641
642            # Check mechanisms
643            if self.nextMechanismID != other.nextMechanismID:
644                yield "nextMechanismID is different"
645
646            if self.mechanisms != other.mechanisms:
647                yield "mechanisms are different"
648
649            if self.globalMechanisms != other.globalMechanisms:
650                yield "global mechanisms are different"
651
652            # Check names:
653            if self.nameLookup != other.nameLookup:
654                for name in self.nameLookup:
655                    if name not in other.nameLookup:
656                        yield (
657                            f"other graph is missing name lookup entry"
658                            f" for {name!r}"
659                        )
660                    else:
661                        mine = self.nameLookup[name]
662                        theirs = other.nameLookup[name]
663                        if theirs != mine:
664                            yield (
665                                f"name lookup for {name!r} is {theirs}"
666                                f" instead of {mine}"
667                            )
668                extras = set(other.nameLookup) - set(self.nameLookup)
669                if extras:
670                    yield (
671                        f"other graph has extra name lookup entries:"
672                        f" {extras}"
673                    )

Generates strings describing differences between this graph and another graph. This does NOT perform graph matching, so it will consider graphs different even if they have identical structures but use different IDs for the nodes in those structures.

def decisionInfo(self, dID: int) -> DecisionInfo:
693    def decisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
694        """
695        Retrieves the decision info for the specified decision, as a
696        live editable dictionary.
697
698        For example:
699
700        >>> g = DecisionGraph()
701        >>> g.addDecision('A')
702        0
703        >>> g.annotateDecision('A', 'note')
704        >>> g.decisionInfo(0)
705        {'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
706        """
707        return cast(DecisionInfo, self.nodes[dID])

Retrieves the decision info for the specified decision, as a live editable dictionary.

For example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.annotateDecision('A', 'note')
>>> g.decisionInfo(0)
{'name': 'A', 'domain': 'main', 'tags': {}, 'annotations': ['note']}
def resolveDecision( self, spec: Union[int, exploration.base.DecisionSpecifier, str], zoneHint: Optional[str] = None, domainHint: Optional[str] = None) -> int:
709    def resolveDecision(
710        self,
711        spec: base.AnyDecisionSpecifier,
712        zoneHint: Optional[base.Zone] = None,
713        domainHint: Optional[base.Domain] = None
714    ) -> base.DecisionID:
715        """
716        Given a decision specifier returns the ID associated with that
717        decision, or raises an `AmbiguousDecisionSpecifierError` or a
718        `MissingDecisionError` if the specified decision is either
719        missing or ambiguous. Cannot handle strings that contain domain
720        and/or zone parts; use
721        `parsing.ParseFormat.parseDecisionSpecifier` to turn such
722        strings into `DecisionSpecifier`s if you need to first.
723
724        Examples:
725
726        >>> g = DecisionGraph()
727        >>> g.addDecision('A')
728        0
729        >>> g.addDecision('B')
730        1
731        >>> g.addDecision('C')
732        2
733        >>> g.addDecision('A')
734        3
735        >>> g.addDecision('B', 'menu')
736        4
737        >>> g.createZone('Z', 0)
738        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
739 annotations=[])
740        >>> g.createZone('Z2', 0)
741        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
742 annotations=[])
743        >>> g.createZone('Zup', 1)
744        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
745 annotations=[])
746        >>> g.addDecisionToZone(0, 'Z')
747        >>> g.addDecisionToZone(1, 'Z')
748        >>> g.addDecisionToZone(2, 'Z')
749        >>> g.addDecisionToZone(3, 'Z2')
750        >>> g.addZoneToZone('Z', 'Zup')
751        >>> g.addZoneToZone('Z2', 'Zup')
752        >>> g.resolveDecision(1)
753        1
754        >>> g.resolveDecision('A')
755        Traceback (most recent call last):
756        ...
757        exploration.core.AmbiguousDecisionSpecifierError...
758        >>> g.resolveDecision('B')
759        Traceback (most recent call last):
760        ...
761        exploration.core.AmbiguousDecisionSpecifierError...
762        >>> g.resolveDecision('C')
763        2
764        >>> g.resolveDecision('A', 'Z')
765        0
766        >>> g.resolveDecision('A', zoneHint='Z2')
767        3
768        >>> g.resolveDecision('B', domainHint='main')
769        1
770        >>> g.resolveDecision('B', None, 'menu')
771        4
772        >>> g.resolveDecision('B', zoneHint='Z2')
773        Traceback (most recent call last):
774        ...
775        exploration.core.MissingDecisionError...
776        >>> g.resolveDecision('A', domainHint='menu')
777        Traceback (most recent call last):
778        ...
779        exploration.core.MissingDecisionError...
780        >>> g.resolveDecision('A', domainHint='madeup')
781        Traceback (most recent call last):
782        ...
783        exploration.core.MissingDecisionError...
784        >>> g.resolveDecision('A', zoneHint='madeup')
785        Traceback (most recent call last):
786        ...
787        exploration.core.MissingDecisionError...
788        """
789        # Parse it to either an ID or specifier if it's a string:
790        if isinstance(spec, str):
791            try:
792                spec = int(spec)
793            except ValueError:
794                pass
795
796        # If it's an ID, check for existence:
797        if isinstance(spec, base.DecisionID):
798            if spec in self:
799                return spec
800            else:
801                raise MissingDecisionError(
802                    f"There is no decision with ID {spec!r}."
803                )
804        else:
805            if isinstance(spec, base.DecisionName):
806                spec = base.DecisionSpecifier(
807                    domain=None,
808                    zone=None,
809                    name=spec
810                )
811            elif not isinstance(spec, base.DecisionSpecifier):
812                raise TypeError(
813                    f"Specification is not provided as a"
814                    f" DecisionSpecifier or other valid type. (got type"
815                    f" {type(spec)})."
816                )
817
818            # Merge domain hints from spec/args
819            if (
820                spec.domain is not None
821            and domainHint is not None
822            and spec.domain != domainHint
823            ):
824                raise ValueError(
825                    f"Specifier {repr(spec)} includes domain hint"
826                    f" {repr(spec.domain)} which is incompatible with"
827                    f" explicit domain hint {repr(domainHint)}."
828                )
829            else:
830                domainHint = spec.domain or domainHint
831
832            # Merge zone hints from spec/args
833            if (
834                spec.zone is not None
835            and zoneHint is not None
836            and spec.zone != zoneHint
837            ):
838                raise ValueError(
839                    f"Specifier {repr(spec)} includes zone hint"
840                    f" {repr(spec.zone)} which is incompatible with"
841                    f" explicit zone hint {repr(zoneHint)}."
842                )
843            else:
844                zoneHint = spec.zone or zoneHint
845
846            if spec.name not in self.nameLookup:
847                raise MissingDecisionError(
848                    f"No decision named {repr(spec.name)}."
849                )
850            else:
851                options = self.nameLookup[spec.name]
852                if len(options) == 0:
853                    raise MissingDecisionError(
854                        f"No decision named {repr(spec.name)}."
855                    )
856                filtered = [
857                    opt
858                    for opt in options
859                    if (
860                        domainHint is None
861                     or self.domainFor(opt) == domainHint
862                    ) and (
863                        zoneHint is None
864                     or zoneHint in self.zoneAncestors(opt)
865                    )
866                ]
867                if len(filtered) == 1:
868                    return filtered[0]
869                else:
870                    filterDesc = ""
871                    if domainHint is not None:
872                        filterDesc += f" in domain {repr(domainHint)}"
873                    if zoneHint is not None:
874                        filterDesc += f" in zone {repr(zoneHint)}"
875                    if len(filtered) == 0:
876                        raise MissingDecisionError(
877                            f"No decisions named"
878                            f" {repr(spec.name)}{filterDesc}."
879                        )
880                    else:
881                        raise AmbiguousDecisionSpecifierError(
882                            f"There are {len(filtered)} decisions"
883                            f" named {repr(spec.name)}{filterDesc}."
884                        )

Given a decision specifier returns the ID associated with that decision, or raises an AmbiguousDecisionSpecifierError or a MissingDecisionError if the specified decision is either missing or ambiguous. Cannot handle strings that contain domain and/or zone parts; use parsing.ParseFormat.parseDecisionSpecifier to turn such strings into DecisionSpecifiers if you need to first.

Examples:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('A')
3
>>> g.addDecision('B', 'menu')
4
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('Zup', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'Z')
>>> g.addDecisionToZone(1, 'Z')
>>> g.addDecisionToZone(2, 'Z')
>>> g.addDecisionToZone(3, 'Z2')
>>> g.addZoneToZone('Z', 'Zup')
>>> g.addZoneToZone('Z2', 'Zup')
>>> g.resolveDecision(1)
1
>>> g.resolveDecision('A')
Traceback (most recent call last):
...
AmbiguousDecisionSpecifierError...
>>> g.resolveDecision('B')
Traceback (most recent call last):
...
AmbiguousDecisionSpecifierError...
>>> g.resolveDecision('C')
2
>>> g.resolveDecision('A', 'Z')
0
>>> g.resolveDecision('A', zoneHint='Z2')
3
>>> g.resolveDecision('B', domainHint='main')
1
>>> g.resolveDecision('B', None, 'menu')
4
>>> g.resolveDecision('B', zoneHint='Z2')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', domainHint='menu')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', domainHint='madeup')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.resolveDecision('A', zoneHint='madeup')
Traceback (most recent call last):
...
MissingDecisionError...
def getDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], zoneHint: Optional[str] = None, domainHint: Optional[str] = None) -> Optional[int]:
886    def getDecision(
887        self,
888        decision: base.AnyDecisionSpecifier,
889        zoneHint: Optional[base.Zone] = None,
890        domainHint: Optional[base.Domain] = None
891    ) -> Optional[base.DecisionID]:
892        """
893        Works like `resolveDecision` but returns None instead of raising
894        a `MissingDecisionError` if the specified decision isn't listed.
895        May still raise an `AmbiguousDecisionSpecifierError`.
896        """
897        try:
898            return self.resolveDecision(
899                decision,
900                zoneHint,
901                domainHint
902            )
903        except MissingDecisionError:
904            return None

Works like resolveDecision but returns None instead of raising a MissingDecisionError if the specified decision isn't listed. May still raise an AmbiguousDecisionSpecifierError.

def nameFor( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> str:
906    def nameFor(
907        self,
908        decision: base.AnyDecisionSpecifier
909    ) -> base.DecisionName:
910        """
911        Returns the name of the specified decision. Note that names are
912        not necessarily unique.
913
914        Example:
915
916        >>> d = DecisionGraph()
917        >>> d.addDecision('A')
918        0
919        >>> d.addDecision('B')
920        1
921        >>> d.addDecision('B')
922        2
923        >>> d.nameFor(0)
924        'A'
925        >>> d.nameFor(1)
926        'B'
927        >>> d.nameFor(2)
928        'B'
929        >>> d.nameFor(3)
930        Traceback (most recent call last):
931        ...
932        exploration.core.MissingDecisionError...
933        """
934        dID = self.resolveDecision(decision)
935        return self.nodes[dID]['name']

Returns the name of the specified decision. Note that names are not necessarily unique.

Example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('B')
2
>>> d.nameFor(0)
'A'
>>> d.nameFor(1)
'B'
>>> d.nameFor(2)
'B'
>>> d.nameFor(3)
Traceback (most recent call last):
...
MissingDecisionError...
def shortIdentity( self, decision: Union[int, exploration.base.DecisionSpecifier, str, NoneType], includeZones: bool = True, alwaysDomain: Optional[bool] = None):
937    def shortIdentity(
938        self,
939        decision: Optional[base.AnyDecisionSpecifier],
940        includeZones: bool = True,
941        alwaysDomain: Optional[bool] = None
942    ):
943        """
944        Returns a string containing the name for the given decision,
945        prefixed by its level-0 zone(s) and domain. If the value provided
946        is `None`, it returns the string "(nowhere)".
947
948        If `includeZones` is true (the default) then zone information
949        is included before the decision name.
950
951        If `alwaysDomain` is true or false, then the domain information
952        will always (or never) be included. If it's `None` (the default)
953        then domain info will only be included for decisions which are
954        not in the default domain.
955        """
956        if decision is None:
957            return "(nowhere)"
958        else:
959            dID = self.resolveDecision(decision)
960            thisDomain = self.domainFor(dID)
961            dSpec = ''
962            zSpec = ''
963            if (
964                alwaysDomain is True
965             or (
966                    alwaysDomain is None
967                and thisDomain != base.DEFAULT_DOMAIN
968                )
969            ):
970                dSpec = thisDomain + '//'  # TODO: Don't hardcode this?
971            if includeZones:
972                zones = [
973                    z
974                    for z in self.zoneParents(dID)
975                    if self.zones[z].level == 0
976                ]
977                if len(zones) == 1:
978                    zSpec = zones[0] + '::'  # TODO: Don't hardcode this?
979                elif len(zones) > 1:
980                    zSpec = '[' + ', '.join(sorted(zones)) + ']::'
981                # else leave zSpec empty
982
983            return f"{dSpec}{zSpec}{self.nameFor(dID)}"

Returns a string containing the name for the given decision, prefixed by its level-0 zone(s) and domain. If the value provided is None, it returns the string "(nowhere)".

If includeZones is true (the default) then zone information is included before the decision name.

If alwaysDomain is true or false, then the domain information will always (or never) be included. If it's None (the default) then domain info will only be included for decisions which are not in the default domain.

def identityOf( self, decision: Union[int, exploration.base.DecisionSpecifier, str, NoneType], includeZones: bool = True, alwaysDomain: Optional[bool] = None) -> str:
 985    def identityOf(
 986        self,
 987        decision: Optional[base.AnyDecisionSpecifier],
 988        includeZones: bool = True,
 989        alwaysDomain: Optional[bool] = None
 990    ) -> str:
 991        """
 992        Returns the given node's ID, plus its `shortIdentity` in
 993        parentheses. Arguments are passed through to `shortIdentity`.
 994        """
 995        if decision is None:
 996            return "(nowhere)"
 997        else:
 998            dID = self.resolveDecision(decision)
 999            short = self.shortIdentity(decision, includeZones, alwaysDomain)
1000            return f"{dID} ({short})"

Returns the given node's ID, plus its shortIdentity in parentheses. Arguments are passed through to shortIdentity.

def namesListing( self, decisions: Collection[int], includeZones: bool = True, indent: int = 2) -> str:
1002    def namesListing(
1003        self,
1004        decisions: Collection[base.DecisionID],
1005        includeZones: bool = True,
1006        indent: int = 2
1007    ) -> str:
1008        """
1009        Returns a multi-line string containing an indented listing of
1010        the provided decision IDs with their names in parentheses after
1011        each. Useful for debugging & error messages.
1012
1013        Includes level-0 zones where applicable, with a zone separator
1014        before the decision, unless `includeZones` is set to False. Where
1015        there are multiple level-0 zones, they're listed together in
1016        brackets.
1017
1018        Uses the string '(none)' when there are no decisions are in the
1019        list.
1020
1021        Set `indent` to something other than 2 to control how much
1022        indentation is added.
1023
1024        For example:
1025
1026        >>> g = DecisionGraph()
1027        >>> g.addDecision('A')
1028        0
1029        >>> g.addDecision('B')
1030        1
1031        >>> g.addDecision('C')
1032        2
1033        >>> g.namesListing(['A', 'C', 'B'])
1034        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
1035        >>> g.namesListing([])
1036        '  (none)\\n'
1037        >>> g.createZone('zone', 0)
1038        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1039 annotations=[])
1040        >>> g.createZone('zone2', 0)
1041        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1042 annotations=[])
1043        >>> g.createZone('zoneUp', 1)
1044        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
1045 annotations=[])
1046        >>> g.addDecisionToZone(0, 'zone')
1047        >>> g.addDecisionToZone(1, 'zone')
1048        >>> g.addDecisionToZone(1, 'zone2')
1049        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
1050        >>> g.namesListing(['A', 'C', 'B'])
1051        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
1052        """
1053        ind = ' ' * indent
1054        if len(decisions) == 0:
1055            return ind + '(none)\n'
1056        else:
1057            result = ''
1058            for dID in decisions:
1059                result += ind + self.identityOf(dID, includeZones) + '\n'
1060            return result

Returns a multi-line string containing an indented listing of the provided decision IDs with their names in parentheses after each. Useful for debugging & error messages.

Includes level-0 zones where applicable, with a zone separator before the decision, unless includeZones is set to False. Where there are multiple level-0 zones, they're listed together in brackets.

Uses the string '(none)' when there are no decisions are in the list.

Set indent to something other than 2 to control how much indentation is added.

For example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.namesListing(['A', 'C', 'B'])
'  0 (A)\n  2 (C)\n  1 (B)\n'
>>> g.namesListing([])
'  (none)\n'
>>> g.createZone('zone', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zoneUp', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'zone')
>>> g.addDecisionToZone(1, 'zone')
>>> g.addDecisionToZone(1, 'zone2')
>>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
>>> g.namesListing(['A', 'C', 'B'])
'  0 (zone::A)\n  2 (C)\n  1 ([zone, zone2]::B)\n'
def destinationsListing( self, destinations: Dict[str, int], includeZones: bool = True, indent: int = 2) -> str:
1062    def destinationsListing(
1063        self,
1064        destinations: Dict[base.Transition, base.DecisionID],
1065        includeZones: bool = True,
1066        indent: int = 2
1067    ) -> str:
1068        """
1069        Returns a multi-line string containing an indented listing of
1070        the provided transitions along with their destinations and the
1071        names of those destinations in parentheses. Useful for debugging
1072        & error messages. (Use e.g., `destinationsFrom` to get a
1073        transitions -> destinations dictionary in the required format.)
1074
1075        Uses the string '(no transitions)' when there are no transitions
1076        in the dictionary.
1077
1078        Set `indent` to something other than 2 to control how much
1079        indentation is added.
1080
1081        For example:
1082
1083        >>> g = DecisionGraph()
1084        >>> g.addDecision('A')
1085        0
1086        >>> g.addDecision('B')
1087        1
1088        >>> g.addDecision('C')
1089        2
1090        >>> g.addTransition('A', 'north', 'B', 'south')
1091        >>> g.addTransition('B', 'east', 'C', 'west')
1092        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
1093        >>> g.destinationsListing(g.destinationsFrom('A'))
1094        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
1095        >>> g.destinationsListing(g.destinationsFrom('B'))
1096        '  south to 0 (A)\\n  east to 2 (C)\\n'
1097        >>> g.destinationsListing({})
1098        '  (none)\\n'
1099        >>> g.createZone('zone', 0)
1100        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1101 annotations=[])
1102        >>> g.addDecisionToZone(0, 'zone')
1103        >>> g.destinationsListing(g.destinationsFrom('B'))
1104        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
1105        """
1106        ind = ' ' * indent
1107        if len(destinations) == 0:
1108            return ind + '(none)\n'
1109        else:
1110            result = ''
1111            for transition, dID in destinations.items():
1112                line = f"{transition} to {self.identityOf(dID, includeZones)}"
1113                result += ind + line + '\n'
1114            return result

Returns a multi-line string containing an indented listing of the provided transitions along with their destinations and the names of those destinations in parentheses. Useful for debugging & error messages. (Use e.g., destinationsFrom to get a transitions -> destinations dictionary in the required format.)

Uses the string '(no transitions)' when there are no transitions in the dictionary.

Set indent to something other than 2 to control how much indentation is added.

For example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'north', 'B', 'south')
>>> g.addTransition('B', 'east', 'C', 'west')
>>> g.addTransition('C', 'southwest', 'A', 'northeast')
>>> g.destinationsListing(g.destinationsFrom('A'))
'  north to 1 (B)\n  northeast to 2 (C)\n'
>>> g.destinationsListing(g.destinationsFrom('B'))
'  south to 0 (A)\n  east to 2 (C)\n'
>>> g.destinationsListing({})
'  (none)\n'
>>> g.createZone('zone', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone(0, 'zone')
>>> g.destinationsListing(g.destinationsFrom('B'))
'  south to 0 (zone::A)\n  east to 2 (C)\n'
def domainFor( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> str:
1116    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
1117        """
1118        Returns the domain that a decision belongs to.
1119        """
1120        dID = self.resolveDecision(decision)
1121        return self.nodes[dID]['domain']

Returns the domain that a decision belongs to.

def allDecisionsInDomain(self, domain: str) -> Set[int]:
1123    def allDecisionsInDomain(
1124        self,
1125        domain: base.Domain
1126    ) -> Set[base.DecisionID]:
1127        """
1128        Returns the set of all `DecisionID`s for decisions in the
1129        specified domain.
1130        """
1131        return set(dID for dID in self if self.nodes[dID]['domain'] == domain)

Returns the set of all DecisionIDs for decisions in the specified domain.

def destination( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> int:
1133    def destination(
1134        self,
1135        decision: base.AnyDecisionSpecifier,
1136        transition: base.Transition
1137    ) -> base.DecisionID:
1138        """
1139        Overrides base `UniqueExitsGraph.destination` to raise
1140        `MissingDecisionError` or `MissingTransitionError` as
1141        appropriate, and to work with an `AnyDecisionSpecifier`.
1142        """
1143        dID = self.resolveDecision(decision)
1144        try:
1145            return super().destination(dID, transition)
1146        except KeyError:
1147            raise MissingTransitionError(
1148                f"Transition {transition!r} does not exist at decision"
1149                f" {self.identityOf(dID)}."
1150            )

Overrides base UniqueExitsGraph.destination to raise MissingDecisionError or MissingTransitionError as appropriate, and to work with an AnyDecisionSpecifier.

def getDestination( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, default: Any = None) -> Optional[int]:
1152    def getDestination(
1153        self,
1154        decision: base.AnyDecisionSpecifier,
1155        transition: base.Transition,
1156        default: Any = None
1157    ) -> Optional[base.DecisionID]:
1158        """
1159        Overrides base `UniqueExitsGraph.getDestination` with different
1160        argument names, since those matter for the edit DSL.
1161        """
1162        dID = self.resolveDecision(decision)
1163        return super().getDestination(dID, transition)

Overrides base UniqueExitsGraph.getDestination with different argument names, since those matter for the edit DSL.

def destinationsFrom( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> Dict[str, int]:
1165    def destinationsFrom(
1166        self,
1167        decision: base.AnyDecisionSpecifier
1168    ) -> Dict[base.Transition, base.DecisionID]:
1169        """
1170        Override that just changes the type of the exception from a
1171        `KeyError` to a `MissingDecisionError` when the source does not
1172        exist.
1173        """
1174        dID = self.resolveDecision(decision)
1175        return super().destinationsFrom(dID)

Override that just changes the type of the exception from a KeyError to a MissingDecisionError when the source does not exist.

def bothEnds( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> Set[int]:
1177    def bothEnds(
1178        self,
1179        decision: base.AnyDecisionSpecifier,
1180        transition: base.Transition
1181    ) -> Set[base.DecisionID]:
1182        """
1183        Returns a set containing the `DecisionID`(s) for both the start
1184        and end of the specified transition. Raises a
1185        `MissingDecisionError` or `MissingTransitionError`if the
1186        specified decision and/or transition do not exist.
1187
1188        Note that for actions since the source and destination are the
1189        same, the set will have only one element.
1190        """
1191        dID = self.resolveDecision(decision)
1192        result = {dID}
1193        dest = self.destination(dID, transition)
1194        if dest is not None:
1195            result.add(dest)
1196        return result

Returns a set containing the DecisionID(s) for both the start and end of the specified transition. Raises a MissingDecisionError or MissingTransitionErrorif the specified decision and/or transition do not exist.

Note that for actions since the source and destination are the same, the set will have only one element.

def decisionActions( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> Set[str]:
1198    def decisionActions(
1199        self,
1200        decision: base.AnyDecisionSpecifier
1201    ) -> Set[base.Transition]:
1202        """
1203        Retrieves the set of self-edges at a decision. Editing the set
1204        will not affect the graph.
1205
1206        Example:
1207
1208        >>> g = DecisionGraph()
1209        >>> g.addDecision('A')
1210        0
1211        >>> g.addDecision('B')
1212        1
1213        >>> g.addDecision('C')
1214        2
1215        >>> g.addAction('A', 'action1')
1216        >>> g.addAction('A', 'action2')
1217        >>> g.addAction('B', 'action3')
1218        >>> sorted(g.decisionActions('A'))
1219        ['action1', 'action2']
1220        >>> g.decisionActions('B')
1221        {'action3'}
1222        >>> g.decisionActions('C')
1223        set()
1224        """
1225        result = set()
1226        dID = self.resolveDecision(decision)
1227        for transition, dest in self.destinationsFrom(dID).items():
1228            if dest == dID:
1229                result.add(transition)
1230        return result

Retrieves the set of self-edges at a decision. Editing the set will not affect the graph.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addAction('A', 'action1')
>>> g.addAction('A', 'action2')
>>> g.addAction('B', 'action3')
>>> sorted(g.decisionActions('A'))
['action1', 'action2']
>>> g.decisionActions('B')
{'action3'}
>>> g.decisionActions('C')
set()
def getTransitionProperties( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> TransitionProperties:
1232    def getTransitionProperties(
1233        self,
1234        decision: base.AnyDecisionSpecifier,
1235        transition: base.Transition
1236    ) -> TransitionProperties:
1237        """
1238        Returns a dictionary containing transition properties for the
1239        specified transition from the specified decision. The properties
1240        included are:
1241
1242        - 'requirement': The requirement for the transition.
1243        - 'consequence': Any consequence of the transition.
1244        - 'tags': Any tags applied to the transition.
1245        - 'annotations': Any annotations on the transition.
1246
1247        The reciprocal of the transition is not included.
1248
1249        The result is a clone of the stored properties; edits to the
1250        dictionary will NOT modify the graph.
1251        """
1252        dID = self.resolveDecision(decision)
1253        dest = self.destination(dID, transition)
1254
1255        info: TransitionProperties = copy.deepcopy(
1256            self.edges[dID, dest, transition]  # type:ignore
1257        )
1258        return {
1259            'requirement': info.get('requirement', base.ReqNothing()),
1260            'consequence': info.get('consequence', []),
1261            'tags': info.get('tags', {}),
1262            'annotations': info.get('annotations', [])
1263        }

Returns a dictionary containing transition properties for the specified transition from the specified decision. The properties included are:

  • 'requirement': The requirement for the transition.
  • 'consequence': Any consequence of the transition.
  • 'tags': Any tags applied to the transition.
  • 'annotations': Any annotations on the transition.

The reciprocal of the transition is not included.

The result is a clone of the stored properties; edits to the dictionary will NOT modify the graph.

def setTransitionProperties( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, requirement: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None) -> None:
1265    def setTransitionProperties(
1266        self,
1267        decision: base.AnyDecisionSpecifier,
1268        transition: base.Transition,
1269        requirement: Optional[base.Requirement] = None,
1270        consequence: Optional[base.Consequence] = None,
1271        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1272        annotations: Optional[List[base.Annotation]] = None
1273    ) -> None:
1274        """
1275        Sets one or more transition properties all at once. Can be used
1276        to set the requirement, consequence, tags, and/or annotations.
1277        Old values are overwritten, although if `None`s are provided (or
1278        arguments are omitted), corresponding properties are not
1279        updated.
1280
1281        To add tags or annotations to existing tags/annotations instead
1282        of replacing them, use `tagTransition` or `annotateTransition`
1283        instead.
1284        """
1285        dID = self.resolveDecision(decision)
1286        if requirement is not None:
1287            self.setTransitionRequirement(dID, transition, requirement)
1288        if consequence is not None:
1289            self.setConsequence(dID, transition, consequence)
1290        if tags is not None:
1291            dest = self.destination(dID, transition)
1292            # TODO: Submit pull request to update MultiDiGraph stubs in
1293            # types-networkx to include OutMultiEdgeView that accepts
1294            # from/to/key tuples as indices.
1295            info = cast(
1296                TransitionProperties,
1297                self.edges[dID, dest, transition]  # type:ignore
1298            )
1299            info['tags'] = tags
1300        if annotations is not None:
1301            dest = self.destination(dID, transition)
1302            info = cast(
1303                TransitionProperties,
1304                self.edges[dID, dest, transition]  # type:ignore
1305            )
1306            info['annotations'] = annotations

Sets one or more transition properties all at once. Can be used to set the requirement, consequence, tags, and/or annotations. Old values are overwritten, although if Nones are provided (or arguments are omitted), corresponding properties are not updated.

To add tags or annotations to existing tags/annotations instead of replacing them, use tagTransition or annotateTransition instead.

def getTransitionRequirement( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> exploration.base.Requirement:
1308    def getTransitionRequirement(
1309        self,
1310        decision: base.AnyDecisionSpecifier,
1311        transition: base.Transition
1312    ) -> base.Requirement:
1313        """
1314        Returns the `Requirement` for accessing a specific transition at
1315        a specific decision. For transitions which don't have
1316        requirements, returns a `ReqNothing` instance.
1317        """
1318        dID = self.resolveDecision(decision)
1319        dest = self.destination(dID, transition)
1320
1321        info = cast(
1322            TransitionProperties,
1323            self.edges[dID, dest, transition]  # type:ignore
1324        )
1325
1326        return info.get('requirement', base.ReqNothing())

Returns the Requirement for accessing a specific transition at a specific decision. For transitions which don't have requirements, returns a ReqNothing instance.

def setTransitionRequirement( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, requirement: Optional[exploration.base.Requirement]) -> None:
1328    def setTransitionRequirement(
1329        self,
1330        decision: base.AnyDecisionSpecifier,
1331        transition: base.Transition,
1332        requirement: Optional[base.Requirement]
1333    ) -> None:
1334        """
1335        Sets the `Requirement` for accessing a specific transition at
1336        a specific decision. Raises a `KeyError` if the decision or
1337        transition does not exist.
1338
1339        Deletes the requirement if `None` is given as the requirement.
1340
1341        Use `parsing.ParseFormat.parseRequirement` first if you have a
1342        requirement in string format.
1343
1344        Does not raise an error if deletion is requested for a
1345        non-existent requirement, and silently overwrites any previous
1346        requirement.
1347        """
1348        dID = self.resolveDecision(decision)
1349
1350        dest = self.destination(dID, transition)
1351
1352        info = cast(
1353            TransitionProperties,
1354            self.edges[dID, dest, transition]  # type:ignore
1355        )
1356
1357        if requirement is None:
1358            try:
1359                del info['requirement']
1360            except KeyError:
1361                pass
1362        else:
1363            if not isinstance(requirement, base.Requirement):
1364                raise TypeError(
1365                    f"Invalid requirement type: {type(requirement)}"
1366                )
1367
1368            info['requirement'] = requirement

Sets the Requirement for accessing a specific transition at a specific decision. Raises a KeyError if the decision or transition does not exist.

Deletes the requirement if None is given as the requirement.

Use parsing.ParseFormat.parseRequirement first if you have a requirement in string format.

Does not raise an error if deletion is requested for a non-existent requirement, and silently overwrites any previous requirement.

def getConsequence( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]:
1370    def getConsequence(
1371        self,
1372        decision: base.AnyDecisionSpecifier,
1373        transition: base.Transition
1374    ) -> base.Consequence:
1375        """
1376        Retrieves the consequence of a transition.
1377
1378        A `KeyError` is raised if the specified decision/transition
1379        combination doesn't exist.
1380        """
1381        dID = self.resolveDecision(decision)
1382
1383        dest = self.destination(dID, transition)
1384
1385        info = cast(
1386            TransitionProperties,
1387            self.edges[dID, dest, transition]  # type:ignore
1388        )
1389
1390        return info.get('consequence', [])

Retrieves the consequence of a transition.

A KeyError is raised if the specified decision/transition combination doesn't exist.

def addConsequence( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]) -> Tuple[int, int]:
1392    def addConsequence(
1393        self,
1394        decision: base.AnyDecisionSpecifier,
1395        transition: base.Transition,
1396        consequence: base.Consequence
1397    ) -> Tuple[int, int]:
1398        """
1399        Adds the given `Consequence` to the consequence list for the
1400        specified transition, extending that list at the end. Note that
1401        this does NOT make a copy of the consequence, so it should not
1402        be used to copy consequences from one transition to another
1403        without making a deep copy first.
1404
1405        A `MissingDecisionError` or a `MissingTransitionError` is raised
1406        if the specified decision/transition combination doesn't exist.
1407
1408        Returns a pair of integers indicating the minimum and maximum
1409        depth-first-traversal-indices of the added consequence part(s).
1410        The outer consequence list itself (index 0) is not counted.
1411
1412        >>> d = DecisionGraph()
1413        >>> d.addDecision('A')
1414        0
1415        >>> d.addDecision('B')
1416        1
1417        >>> d.addTransition('A', 'fwd', 'B', 'rev')
1418        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
1419        (1, 1)
1420        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
1421        (1, 1)
1422        >>> ef = d.getConsequence('A', 'fwd')
1423        >>> er = d.getConsequence('B', 'rev')
1424        >>> ef == [base.effect(gain='sword')]
1425        True
1426        >>> er == [base.effect(lose='sword')]
1427        True
1428        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
1429        (2, 2)
1430        >>> ef = d.getConsequence('A', 'fwd')
1431        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
1432        True
1433        >>> d.addConsequence(
1434        ...     'A',
1435        ...     'fwd',  # adding to consequence with 3 parts already
1436        ...     [  # outer list not counted because it merges
1437        ...         base.challenge(  # 1 part
1438        ...             None,
1439        ...             0,
1440        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
1441        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
1442        ...         )
1443        ...     ]
1444        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
1445        (3, 7)
1446        """
1447        dID = self.resolveDecision(decision)
1448
1449        dest = self.destination(dID, transition)
1450
1451        info = cast(
1452            TransitionProperties,
1453            self.edges[dID, dest, transition]  # type:ignore
1454        )
1455
1456        existing = info.setdefault('consequence', [])
1457        startIndex = base.countParts(existing)
1458        existing.extend(consequence)
1459        endIndex = base.countParts(existing) - 1
1460        return (startIndex, endIndex)

Adds the given Consequence to the consequence list for the specified transition, extending that list at the end. Note that this does NOT make a copy of the consequence, so it should not be used to copy consequences from one transition to another without making a deep copy first.

A MissingDecisionError or a MissingTransitionError is raised if the specified decision/transition combination doesn't exist.

Returns a pair of integers indicating the minimum and maximum depth-first-traversal-indices of the added consequence part(s). The outer consequence list itself (index 0) is not counted.

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addTransition('A', 'fwd', 'B', 'rev')
>>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
(1, 1)
>>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
(1, 1)
>>> ef = d.getConsequence('A', 'fwd')
>>> er = d.getConsequence('B', 'rev')
>>> ef == [base.effect(gain='sword')]
True
>>> er == [base.effect(lose='sword')]
True
>>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
(2, 2)
>>> ef = d.getConsequence('A', 'fwd')
>>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
True
>>> d.addConsequence(
...     'A',
...     'fwd',  # adding to consequence with 3 parts already
...     [  # outer list not counted because it merges
...         base.challenge(  # 1 part
...             None,
...             0,
...             [base.effect(gain=('flowers', 3))],  # 2 parts
...             [base.effect(gain=('flowers', 1))]  # 2 parts
...         )
...     ]
... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
(3, 7)
def setConsequence( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]) -> None:
1462    def setConsequence(
1463        self,
1464        decision: base.AnyDecisionSpecifier,
1465        transition: base.Transition,
1466        consequence: base.Consequence
1467    ) -> None:
1468        """
1469        Replaces the transition consequence for the given transition at
1470        the given decision. Any previous consequence is discarded. See
1471        `Consequence` for the structure of these. Note that this does
1472        NOT make a copy of the consequence, do that first to avoid
1473        effect-entanglement if you're copying a consequence.
1474
1475        A `MissingDecisionError` or a `MissingTransitionError` is raised
1476        if the specified decision/transition combination doesn't exist.
1477        """
1478        dID = self.resolveDecision(decision)
1479
1480        dest = self.destination(dID, transition)
1481
1482        info = cast(
1483            TransitionProperties,
1484            self.edges[dID, dest, transition]  # type:ignore
1485        )
1486
1487        info['consequence'] = consequence

Replaces the transition consequence for the given transition at the given decision. Any previous consequence is discarded. See Consequence for the structure of these. Note that this does NOT make a copy of the consequence, do that first to avoid effect-entanglement if you're copying a consequence.

A MissingDecisionError or a MissingTransitionError is raised if the specified decision/transition combination doesn't exist.

def addEquivalence( self, requirement: exploration.base.Requirement, capabilityOrMechanismState: Union[str, Tuple[int, str]]) -> None:
1489    def addEquivalence(
1490        self,
1491        requirement: base.Requirement,
1492        capabilityOrMechanismState: Union[
1493            base.Capability,
1494            Tuple[base.MechanismID, base.MechanismState]
1495        ]
1496    ) -> None:
1497        """
1498        Adds the given requirement as an equivalence for the given
1499        capability or the given mechanism state. Note that having a
1500        capability via an equivalence does not count as actually having
1501        that capability; it only counts for the purpose of satisfying
1502        `Requirement`s.
1503
1504        Note also that because a mechanism-based requirement looks up
1505        the specific mechanism locally based on a name, an equivalence
1506        defined in one location may affect mechanism requirements in
1507        other locations unless the mechanism name in the requirement is
1508        zone-qualified to be specific. But in such situations the base
1509        mechanism would have caused issues in any case.
1510        """
1511        self.equivalences.setdefault(
1512            capabilityOrMechanismState,
1513            set()
1514        ).add(requirement)

Adds the given requirement as an equivalence for the given capability or the given mechanism state. Note that having a capability via an equivalence does not count as actually having that capability; it only counts for the purpose of satisfying Requirements.

Note also that because a mechanism-based requirement looks up the specific mechanism locally based on a name, an equivalence defined in one location may affect mechanism requirements in other locations unless the mechanism name in the requirement is zone-qualified to be specific. But in such situations the base mechanism would have caused issues in any case.

def removeEquivalence( self, requirement: exploration.base.Requirement, capabilityOrMechanismState: Union[str, Tuple[int, str]]) -> None:
1516    def removeEquivalence(
1517        self,
1518        requirement: base.Requirement,
1519        capabilityOrMechanismState: Union[
1520            base.Capability,
1521            Tuple[base.MechanismID, base.MechanismState]
1522        ]
1523    ) -> None:
1524        """
1525        Removes an equivalence. Raises a `KeyError` if no such
1526        equivalence existed.
1527        """
1528        self.equivalences[capabilityOrMechanismState].remove(requirement)

Removes an equivalence. Raises a KeyError if no such equivalence existed.

def hasAnyEquivalents(self, capabilityOrMechanismState: Union[str, Tuple[int, str]]) -> bool:
1530    def hasAnyEquivalents(
1531        self,
1532        capabilityOrMechanismState: Union[
1533            base.Capability,
1534            Tuple[base.MechanismID, base.MechanismState]
1535        ]
1536    ) -> bool:
1537        """
1538        Returns `True` if the given capability or mechanism state has at
1539        least one equivalence.
1540        """
1541        return capabilityOrMechanismState in self.equivalences

Returns True if the given capability or mechanism state has at least one equivalence.

def allEquivalents( self, capabilityOrMechanismState: Union[str, Tuple[int, str]]) -> Set[exploration.base.Requirement]:
1543    def allEquivalents(
1544        self,
1545        capabilityOrMechanismState: Union[
1546            base.Capability,
1547            Tuple[base.MechanismID, base.MechanismState]
1548        ]
1549    ) -> Set[base.Requirement]:
1550        """
1551        Returns the set of equivalences for the given capability. This is
1552        a live set which may be modified (it's probably better to use
1553        `addEquivalence` and `removeEquivalence` instead...).
1554        """
1555        return self.equivalences.setdefault(
1556            capabilityOrMechanismState,
1557            set()
1558        )

Returns the set of equivalences for the given capability. This is a live set which may be modified (it's probably better to use addEquivalence and removeEquivalence instead...).

def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
1560    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
1561        """
1562        Specifies a new reversion type, so that when used in a reversion
1563        aspects set with a colon before the name, all items in the
1564        `equivalentTo` value will be added to that set. These may
1565        include other custom reversion type names (with the colon) but
1566        take care not to create an equivalence loop which would result
1567        in a crash.
1568
1569        If you re-use the same name, it will override the old equivalence
1570        for that name.
1571        """
1572        self.reversionTypes[name] = equivalentTo

Specifies a new reversion type, so that when used in a reversion aspects set with a colon before the name, all items in the equivalentTo value will be added to that set. These may include other custom reversion type names (with the colon) but take care not to create an equivalence loop which would result in a crash.

If you re-use the same name, it will override the old equivalence for that name.

def addAction( self, decision: Union[int, exploration.base.DecisionSpecifier, str], action: str, requires: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None) -> None:
1574    def addAction(
1575        self,
1576        decision: base.AnyDecisionSpecifier,
1577        action: base.Transition,
1578        requires: Optional[base.Requirement] = None,
1579        consequence: Optional[base.Consequence] = None,
1580        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1581        annotations: Optional[List[base.Annotation]] = None,
1582    ) -> None:
1583        """
1584        Adds the given action as a possibility at the given decision. An
1585        action is just a self-edge, which can have requirements like any
1586        edge, and which can have consequences like any edge.
1587        The optional arguments are given to `setTransitionRequirement`
1588        and `setConsequence`; see those functions for descriptions
1589        of what they mean.
1590
1591        Raises a `KeyError` if a transition with the given name already
1592        exists at the given decision.
1593        """
1594        if tags is None:
1595            tags = {}
1596        if annotations is None:
1597            annotations = []
1598
1599        dID = self.resolveDecision(decision)
1600
1601        self.add_edge(
1602            dID,
1603            dID,
1604            key=action,
1605            tags=tags,
1606            annotations=annotations
1607        )
1608        self.setTransitionRequirement(dID, action, requires)
1609        if consequence is not None:
1610            self.setConsequence(dID, action, consequence)

Adds the given action as a possibility at the given decision. An action is just a self-edge, which can have requirements like any edge, and which can have consequences like any edge. The optional arguments are given to setTransitionRequirement and setConsequence; see those functions for descriptions of what they mean.

Raises a KeyError if a transition with the given name already exists at the given decision.

def tagDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], tagOrTags: Union[str, Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]], tagValue: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]] = <class 'exploration.base.NoTagValue'>) -> None:
1612    def tagDecision(
1613        self,
1614        decision: base.AnyDecisionSpecifier,
1615        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1616        tagValue: Union[
1617            base.TagValue,
1618            type[base.NoTagValue]
1619        ] = base.NoTagValue
1620    ) -> None:
1621        """
1622        Adds a tag (or many tags from a dictionary of tags) to a
1623        decision, using `1` as the value if no value is provided. It's
1624        a `ValueError` to provide a value when a dictionary of tags is
1625        provided to set multiple tags at once.
1626
1627        Note that certain tags have special meanings:
1628
1629        - 'unconfirmed' is used for decisions that represent unconfirmed
1630            parts of the graph (this is separate from the 'unknown'
1631            and/or 'hypothesized' exploration statuses, which are only
1632            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
1633            Various methods require this tag and many also add or remove
1634            it.
1635        """
1636        if isinstance(tagOrTags, base.Tag):
1637            if tagValue is base.NoTagValue:
1638                tagValue = 1
1639
1640            # Not sure why this cast is necessary given the `if` above...
1641            tagValue = cast(base.TagValue, tagValue)
1642
1643            tagOrTags = {tagOrTags: tagValue}
1644
1645        elif tagValue is not base.NoTagValue:
1646            raise ValueError(
1647                "Provided a dictionary to update multiple tags, but"
1648                " also a tag value."
1649            )
1650
1651        dID = self.resolveDecision(decision)
1652
1653        tagsAlready = self.nodes[dID].setdefault('tags', {})
1654        tagsAlready.update(tagOrTags)

Adds a tag (or many tags from a dictionary of tags) to a decision, using 1 as the value if no value is provided. It's a ValueError to provide a value when a dictionary of tags is provided to set multiple tags at once.

Note that certain tags have special meanings:

  • 'unconfirmed' is used for decisions that represent unconfirmed parts of the graph (this is separate from the 'unknown' and/or 'hypothesized' exploration statuses, which are only tracked in a DiscreteExploration, not in a DecisionGraph). Various methods require this tag and many also add or remove it.
def untagDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], tag: str) -> Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]]:
1656    def untagDecision(
1657        self,
1658        decision: base.AnyDecisionSpecifier,
1659        tag: base.Tag
1660    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1661        """
1662        Removes a tag from a decision. Returns the tag's old value if
1663        the tag was present and got removed, or `NoTagValue` if the tag
1664        wasn't present.
1665        """
1666        dID = self.resolveDecision(decision)
1667
1668        target = self.nodes[dID]['tags']
1669        try:
1670            return target.pop(tag)
1671        except KeyError:
1672            return base.NoTagValue

Removes a tag from a decision. Returns the tag's old value if the tag was present and got removed, or NoTagValue if the tag wasn't present.

def decisionTags( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]:
1674    def decisionTags(
1675        self,
1676        decision: base.AnyDecisionSpecifier
1677    ) -> Dict[base.Tag, base.TagValue]:
1678        """
1679        Returns the dictionary of tags for a decision. Edits to the
1680        returned value will be applied to the graph.
1681        """
1682        dID = self.resolveDecision(decision)
1683
1684        return self.nodes[dID]['tags']

Returns the dictionary of tags for a decision. Edits to the returned value will be applied to the graph.

def annotateDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], annotationOrAnnotations: Union[str, Sequence[str]]) -> None:
1686    def annotateDecision(
1687        self,
1688        decision: base.AnyDecisionSpecifier,
1689        annotationOrAnnotations: Union[
1690            base.Annotation,
1691            Sequence[base.Annotation]
1692        ]
1693    ) -> None:
1694        """
1695        Adds an annotation to a decision's annotations list.
1696        """
1697        dID = self.resolveDecision(decision)
1698
1699        if isinstance(annotationOrAnnotations, base.Annotation):
1700            annotationOrAnnotations = [annotationOrAnnotations]
1701        self.nodes[dID]['annotations'].extend(annotationOrAnnotations)

Adds an annotation to a decision's annotations list.

def decisionAnnotations( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> List[str]:
1703    def decisionAnnotations(
1704        self,
1705        decision: base.AnyDecisionSpecifier
1706    ) -> List[base.Annotation]:
1707        """
1708        Returns the list of annotations for the specified decision.
1709        Modifying the list affects the graph.
1710        """
1711        dID = self.resolveDecision(decision)
1712
1713        return self.nodes[dID]['annotations']

Returns the list of annotations for the specified decision. Modifying the list affects the graph.

def tagTransition( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, tagOrTags: Union[str, Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]], tagValue: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]] = <class 'exploration.base.NoTagValue'>) -> None:
1715    def tagTransition(
1716        self,
1717        decision: base.AnyDecisionSpecifier,
1718        transition: base.Transition,
1719        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1720        tagValue: Union[
1721            base.TagValue,
1722            type[base.NoTagValue]
1723        ] = base.NoTagValue
1724    ) -> None:
1725        """
1726        Adds a tag (or each tag from a dictionary) to a transition
1727        coming out of a specific decision. `1` will be used as the
1728        default value if a single tag is supplied; supplying a tag value
1729        when providing a dictionary of multiple tags to update is a
1730        `ValueError`.
1731
1732        Note that certain transition tags have special meanings:
1733        - 'trigger' causes any actions (but not normal transitions) that
1734            it applies to to be automatically triggered when
1735            `advanceSituation` is called and the decision they're
1736            attached to is active in the new situation (as long as the
1737            action's requirements are met). This happens once per
1738            situation; use 'wait' steps to re-apply triggers.
1739        """
1740        dID = self.resolveDecision(decision)
1741
1742        dest = self.destination(dID, transition)
1743        if isinstance(tagOrTags, base.Tag):
1744            if tagValue is base.NoTagValue:
1745                tagValue = 1
1746
1747            # Not sure why this is necessary given the `if` above...
1748            tagValue = cast(base.TagValue, tagValue)
1749
1750            tagOrTags = {tagOrTags: tagValue}
1751        elif tagValue is not base.NoTagValue:
1752            raise ValueError(
1753                "Provided a dictionary to update multiple tags, but"
1754                " also a tag value."
1755            )
1756
1757        info = cast(
1758            TransitionProperties,
1759            self.edges[dID, dest, transition]  # type:ignore
1760        )
1761
1762        info.setdefault('tags', {}).update(tagOrTags)

Adds a tag (or each tag from a dictionary) to a transition coming out of a specific decision. 1 will be used as the default value if a single tag is supplied; supplying a tag value when providing a dictionary of multiple tags to update is a ValueError.

Note that certain transition tags have special meanings:

  • 'trigger' causes any actions (but not normal transitions) that it applies to to be automatically triggered when advanceSituation is called and the decision they're attached to is active in the new situation (as long as the action's requirements are met). This happens once per situation; use 'wait' steps to re-apply triggers.
def untagTransition( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, tagOrTags: Union[str, Set[str]]) -> None:
1764    def untagTransition(
1765        self,
1766        decision: base.AnyDecisionSpecifier,
1767        transition: base.Transition,
1768        tagOrTags: Union[base.Tag, Set[base.Tag]]
1769    ) -> None:
1770        """
1771        Removes a tag (or each tag in a set) from a transition coming out
1772        of a specific decision. Raises a `KeyError` if (one of) the
1773        specified tag(s) is not currently applied to the specified
1774        transition.
1775        """
1776        dID = self.resolveDecision(decision)
1777
1778        dest = self.destination(dID, transition)
1779        if isinstance(tagOrTags, base.Tag):
1780            tagOrTags = {tagOrTags}
1781
1782        info = cast(
1783            TransitionProperties,
1784            self.edges[dID, dest, transition]  # type:ignore
1785        )
1786        tagsAlready = info.setdefault('tags', {})
1787
1788        for tag in tagOrTags:
1789            tagsAlready.pop(tag)

Removes a tag (or each tag in a set) from a transition coming out of a specific decision. Raises a KeyError if (one of) the specified tag(s) is not currently applied to the specified transition.

def transitionTags( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]:
1791    def transitionTags(
1792        self,
1793        decision: base.AnyDecisionSpecifier,
1794        transition: base.Transition
1795    ) -> Dict[base.Tag, base.TagValue]:
1796        """
1797        Returns the dictionary of tags for a transition. Edits to the
1798        returned dictionary will be applied to the graph.
1799        """
1800        dID = self.resolveDecision(decision)
1801
1802        dest = self.destination(dID, transition)
1803        info = cast(
1804            TransitionProperties,
1805            self.edges[dID, dest, transition]  # type:ignore
1806        )
1807        return info.setdefault('tags', {})

Returns the dictionary of tags for a transition. Edits to the returned dictionary will be applied to the graph.

def annotateTransition( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, annotations: Union[str, Sequence[str]]) -> None:
1809    def annotateTransition(
1810        self,
1811        decision: base.AnyDecisionSpecifier,
1812        transition: base.Transition,
1813        annotations: Union[base.Annotation, Sequence[base.Annotation]]
1814    ) -> None:
1815        """
1816        Adds an annotation (or a sequence of annotations) to a
1817        transition's annotations list.
1818        """
1819        dID = self.resolveDecision(decision)
1820
1821        dest = self.destination(dID, transition)
1822        if isinstance(annotations, base.Annotation):
1823            annotations = [annotations]
1824        info = cast(
1825            TransitionProperties,
1826            self.edges[dID, dest, transition]  # type:ignore
1827        )
1828        info['annotations'].extend(annotations)

Adds an annotation (or a sequence of annotations) to a transition's annotations list.

def transitionAnnotations( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> List[str]:
1830    def transitionAnnotations(
1831        self,
1832        decision: base.AnyDecisionSpecifier,
1833        transition: base.Transition
1834    ) -> List[base.Annotation]:
1835        """
1836        Returns the annotation list for a specific transition at a
1837        specific decision. Editing the list affects the graph.
1838        """
1839        dID = self.resolveDecision(decision)
1840
1841        dest = self.destination(dID, transition)
1842        info = cast(
1843            TransitionProperties,
1844            self.edges[dID, dest, transition]  # type:ignore
1845        )
1846        return info['annotations']

Returns the annotation list for a specific transition at a specific decision. Editing the list affects the graph.

def annotateZone(self, zone: str, annotations: Union[str, Sequence[str]]) -> None:
1848    def annotateZone(
1849        self,
1850        zone: base.Zone,
1851        annotations: Union[base.Annotation, Sequence[base.Annotation]]
1852    ) -> None:
1853        """
1854        Adds an annotation (or many annotations from a sequence) to a
1855        zone.
1856
1857        Raises a `MissingZoneError` if the specified zone does not exist.
1858        """
1859        if zone not in self.zones:
1860            raise MissingZoneError(
1861                f"Can't add annotation(s) to zone {zone!r} because that"
1862                f" zone doesn't exist yet."
1863            )
1864
1865        if isinstance(annotations, base.Annotation):
1866            annotations = [ annotations ]
1867
1868        self.zones[zone].annotations.extend(annotations)

Adds an annotation (or many annotations from a sequence) to a zone.

Raises a MissingZoneError if the specified zone does not exist.

def zoneAnnotations(self, zone: str) -> List[str]:
1870    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
1871        """
1872        Returns the list of annotations for the specified zone (empty if
1873        none have been added yet).
1874        """
1875        return self.zones[zone].annotations

Returns the list of annotations for the specified zone (empty if none have been added yet).

def tagZone( self, zone: str, tagOrTags: Union[str, Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]], tagValue: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]] = <class 'exploration.base.NoTagValue'>) -> None:
1877    def tagZone(
1878        self,
1879        zone: base.Zone,
1880        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1881        tagValue: Union[
1882            base.TagValue,
1883            type[base.NoTagValue]
1884        ] = base.NoTagValue
1885    ) -> None:
1886        """
1887        Adds a tag (or many tags from a dictionary of tags) to a
1888        zone, using `1` as the value if no value is provided. It's
1889        a `ValueError` to provide a value when a dictionary of tags is
1890        provided to set multiple tags at once.
1891
1892        Raises a `MissingZoneError` if the specified zone does not exist.
1893        """
1894        if zone not in self.zones:
1895            raise MissingZoneError(
1896                f"Can't add tag(s) to zone {zone!r} because that zone"
1897                f" doesn't exist yet."
1898            )
1899
1900        if isinstance(tagOrTags, base.Tag):
1901            if tagValue is base.NoTagValue:
1902                tagValue = 1
1903
1904            # Not sure why this cast is necessary given the `if` above...
1905            tagValue = cast(base.TagValue, tagValue)
1906
1907            tagOrTags = {tagOrTags: tagValue}
1908
1909        elif tagValue is not base.NoTagValue:
1910            raise ValueError(
1911                "Provided a dictionary to update multiple tags, but"
1912                " also a tag value."
1913            )
1914
1915        tagsAlready = self.zones[zone].tags
1916        tagsAlready.update(tagOrTags)

Adds a tag (or many tags from a dictionary of tags) to a zone, using 1 as the value if no value is provided. It's a ValueError to provide a value when a dictionary of tags is provided to set multiple tags at once.

Raises a MissingZoneError if the specified zone does not exist.

def untagZone( self, zone: str, tag: str) -> Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]]:
1918    def untagZone(
1919        self,
1920        zone: base.Zone,
1921        tag: base.Tag
1922    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1923        """
1924        Removes a tag from a zone. Returns the tag's old value if the
1925        tag was present and got removed, or `NoTagValue` if the tag
1926        wasn't present.
1927
1928        Raises a `MissingZoneError` if the specified zone does not exist.
1929        """
1930        if zone not in self.zones:
1931            raise MissingZoneError(
1932                f"Can't remove tag {tag!r} from zone {zone!r} because"
1933                f" that zone doesn't exist yet."
1934            )
1935        target = self.zones[zone].tags
1936        try:
1937            return target.pop(tag)
1938        except KeyError:
1939            return base.NoTagValue

Removes a tag from a zone. Returns the tag's old value if the tag was present and got removed, or NoTagValue if the tag wasn't present.

Raises a MissingZoneError if the specified zone does not exist.

def zoneTags( self, zone: str) -> Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]:
1941    def zoneTags(
1942        self,
1943        zone: base.Zone
1944    ) -> Dict[base.Tag, base.TagValue]:
1945        """
1946        Returns the dictionary of tags for a zone. Edits to the returned
1947        value will be applied to the graph. Returns an empty tags
1948        dictionary if called on a zone that didn't have any tags
1949        previously, but raises a `MissingZoneError` if attempting to get
1950        tags for a zone which does not exist.
1951
1952        For example:
1953
1954        >>> g = DecisionGraph()
1955        >>> g.addDecision('A')
1956        0
1957        >>> g.addDecision('B')
1958        1
1959        >>> g.createZone('Zone')
1960        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1961 annotations=[])
1962        >>> g.tagZone('Zone', 'color', 'blue')
1963        >>> g.tagZone(
1964        ...     'Zone',
1965        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
1966        ... )
1967        >>> g.untagZone('Zone', 'sound')
1968        'loud'
1969        >>> g.zoneTags('Zone')
1970        {'color': 'red', 'shape': 'square'}
1971        """
1972        if zone in self.zones:
1973            return self.zones[zone].tags
1974        else:
1975            raise MissingZoneError(
1976                f"Tags for zone {zone!r} don't exist because that"
1977                f" zone has not been created yet."
1978            )

Returns the dictionary of tags for a zone. Edits to the returned value will be applied to the graph. Returns an empty tags dictionary if called on a zone that didn't have any tags previously, but raises a MissingZoneError if attempting to get tags for a zone which does not exist.

For example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('Zone')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.tagZone('Zone', 'color', 'blue')
>>> g.tagZone(
...     'Zone',
...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
... )
>>> g.untagZone('Zone', 'sound')
'loud'
>>> g.zoneTags('Zone')
{'color': 'red', 'shape': 'square'}
def createZone(self, zone: str, level: int = 0) -> exploration.base.ZoneInfo:
1980    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
1981        """
1982        Creates an empty zone with the given name at the given level
1983        (default 0). Raises a `ZoneCollisionError` if that zone name is
1984        already in use (at any level), including if it's in use by a
1985        decision.
1986
1987        Raises an `InvalidLevelError` if the level value is less than 0.
1988
1989        Returns the `ZoneInfo` for the new blank zone.
1990
1991        For example:
1992
1993        >>> d = DecisionGraph()
1994        >>> d.createZone('Z', 0)
1995        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1996 annotations=[])
1997        >>> d.getZoneInfo('Z')
1998        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1999 annotations=[])
2000        >>> d.createZone('Z2', 0)
2001        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2002 annotations=[])
2003        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
2004        Traceback (most recent call last):
2005        ...
2006        exploration.core.InvalidLevelError...
2007        >>> d.createZone('Z2')  # Name Z2 is already in use
2008        Traceback (most recent call last):
2009        ...
2010        exploration.core.ZoneCollisionError...
2011        """
2012        if level < 0:
2013            raise InvalidLevelError(
2014                "Cannot create a zone with a negative level."
2015            )
2016        if zone in self.zones:
2017            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
2018        if zone in self:
2019            raise ZoneCollisionError(
2020                f"A decision named {zone!r} already exists, so a zone"
2021                f" with that name cannot be created."
2022            )
2023        info: base.ZoneInfo = base.ZoneInfo(
2024            level=level,
2025            parents=set(),
2026            contents=set(),
2027            tags={},
2028            annotations=[]
2029        )
2030        self.zones[zone] = info
2031        return info

Creates an empty zone with the given name at the given level (default 0). Raises a ZoneCollisionError if that zone name is already in use (at any level), including if it's in use by a decision.

Raises an InvalidLevelError if the level value is less than 0.

Returns the ZoneInfo for the new blank zone.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
Traceback (most recent call last):
...
InvalidLevelError...
>>> d.createZone('Z2')  # Name Z2 is already in use
Traceback (most recent call last):
...
ZoneCollisionError...
def getZoneInfo(self, zone: str) -> Optional[exploration.base.ZoneInfo]:
2033    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
2034        """
2035        Returns the `ZoneInfo` (level, parents, and contents) for the
2036        specified zone, or `None` if that zone does not exist.
2037
2038        For example:
2039
2040        >>> d = DecisionGraph()
2041        >>> d.createZone('Z', 0)
2042        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2043 annotations=[])
2044        >>> d.getZoneInfo('Z')
2045        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2046 annotations=[])
2047        >>> d.createZone('Z2', 0)
2048        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2049 annotations=[])
2050        >>> d.getZoneInfo('Z2')
2051        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2052 annotations=[])
2053        """
2054        return self.zones.get(zone)

Returns the ZoneInfo (level, parents, and contents) for the specified zone, or None if that zone does not exist.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
def deleteZone(self, zone: str) -> exploration.base.ZoneInfo:
2056    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
2057        """
2058        Deletes the specified zone, returning a `ZoneInfo` object with
2059        the information on the level, parents, and contents of that zone.
2060
2061        Raises a `MissingZoneError` if the zone in question does not
2062        exist.
2063
2064        For example:
2065
2066        >>> d = DecisionGraph()
2067        >>> d.createZone('Z', 0)
2068        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2069 annotations=[])
2070        >>> d.getZoneInfo('Z')
2071        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2072 annotations=[])
2073        >>> d.deleteZone('Z')
2074        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2075 annotations=[])
2076        >>> d.getZoneInfo('Z') is None  # no info any more
2077        True
2078        >>> d.deleteZone('Z')  # can't re-delete
2079        Traceback (most recent call last):
2080        ...
2081        exploration.core.MissingZoneError...
2082        """
2083        info = self.getZoneInfo(zone)
2084        if info is None:
2085            raise MissingZoneError(
2086                f"Cannot delete zone {zone!r}: it does not exist."
2087            )
2088        for sub in info.contents:
2089            if 'zones' in self.nodes[sub]:
2090                try:
2091                    self.nodes[sub]['zones'].remove(zone)
2092                except KeyError:
2093                    pass
2094        del self.zones[zone]
2095        return info

Deletes the specified zone, returning a ZoneInfo object with the information on the level, parents, and contents of that zone.

Raises a MissingZoneError if the zone in question does not exist.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.deleteZone('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('Z') is None  # no info any more
True
>>> d.deleteZone('Z')  # can't re-delete
Traceback (most recent call last):
...
MissingZoneError...
def addDecisionToZone( self, decision: Union[int, exploration.base.DecisionSpecifier, str], zone: str) -> None:
2097    def addDecisionToZone(
2098        self,
2099        decision: base.AnyDecisionSpecifier,
2100        zone: base.Zone
2101    ) -> None:
2102        """
2103        Adds a decision directly to a zone. Should normally only be used
2104        with level-0 zones. Raises a `MissingZoneError` if the specified
2105        zone did not already exist.
2106
2107        For example:
2108
2109        >>> d = DecisionGraph()
2110        >>> d.addDecision('A')
2111        0
2112        >>> d.addDecision('B')
2113        1
2114        >>> d.addDecision('C')
2115        2
2116        >>> d.createZone('Z', 0)
2117        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2118 annotations=[])
2119        >>> d.addDecisionToZone('A', 'Z')
2120        >>> d.getZoneInfo('Z')
2121        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
2122 annotations=[])
2123        >>> d.addDecisionToZone('B', 'Z')
2124        >>> d.getZoneInfo('Z')
2125        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2126 annotations=[])
2127        """
2128        dID = self.resolveDecision(decision)
2129
2130        if zone not in self.zones:
2131            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2132
2133        self.zones[zone].contents.add(dID)
2134        self.nodes[dID].setdefault('zones', set()).add(zone)

Adds a decision directly to a zone. Should normally only be used with level-0 zones. Raises a MissingZoneError if the specified zone did not already exist.

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0}, tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
def removeDecisionFromZone( self, decision: Union[int, exploration.base.DecisionSpecifier, str], zone: str) -> bool:
2136    def removeDecisionFromZone(
2137        self,
2138        decision: base.AnyDecisionSpecifier,
2139        zone: base.Zone
2140    ) -> bool:
2141        """
2142        Removes a decision from a zone if it had been in it, returning
2143        True if that decision had been in that zone, and False if it was
2144        not in that zone, including if that zone didn't exist.
2145
2146        Note that this only removes a decision from direct zone
2147        membership. If the decision is a member of one or more zones
2148        which are (directly or indirectly) sub-zones of the target zone,
2149        the decision will remain in those zones, and will still be
2150        indirectly part of the target zone afterwards.
2151
2152        Examples:
2153
2154        >>> g = DecisionGraph()
2155        >>> g.addDecision('A')
2156        0
2157        >>> g.addDecision('B')
2158        1
2159        >>> g.createZone('level0', 0)
2160        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2161 annotations=[])
2162        >>> g.createZone('level1', 1)
2163        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2164 annotations=[])
2165        >>> g.createZone('level2', 2)
2166        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2167 annotations=[])
2168        >>> g.createZone('level3', 3)
2169        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2170 annotations=[])
2171        >>> g.addDecisionToZone('A', 'level0')
2172        >>> g.addDecisionToZone('B', 'level0')
2173        >>> g.addZoneToZone('level0', 'level1')
2174        >>> g.addZoneToZone('level1', 'level2')
2175        >>> g.addZoneToZone('level2', 'level3')
2176        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2177        >>> g.removeDecisionFromZone('A', 'level1')
2178        False
2179        >>> g.zoneParents(0)
2180        {'level0'}
2181        >>> g.removeDecisionFromZone('A', 'level0')
2182        True
2183        >>> g.zoneParents(0)
2184        set()
2185        >>> g.removeDecisionFromZone('A', 'level0')
2186        False
2187        >>> g.removeDecisionFromZone('B', 'level0')
2188        True
2189        >>> g.zoneParents(1)
2190        {'level2'}
2191        >>> g.removeDecisionFromZone('B', 'level0')
2192        False
2193        >>> g.removeDecisionFromZone('B', 'level2')
2194        True
2195        >>> g.zoneParents(1)
2196        set()
2197        """
2198        dID = self.resolveDecision(decision)
2199
2200        if zone not in self.zones:
2201            return False
2202
2203        info = self.zones[zone]
2204        if dID not in info.contents:
2205            return False
2206        else:
2207            info.contents.remove(dID)
2208            try:
2209                self.nodes[dID]['zones'].remove(zone)
2210            except KeyError:
2211                pass
2212            return True

Removes a decision from a zone if it had been in it, returning True if that decision had been in that zone, and False if it was not in that zone, including if that zone didn't exist.

Note that this only removes a decision from direct zone membership. If the decision is a member of one or more zones which are (directly or indirectly) sub-zones of the target zone, the decision will remain in those zones, and will still be indirectly part of the target zone afterwards.

Examples:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
>>> g.removeDecisionFromZone('A', 'level1')
False
>>> g.zoneParents(0)
{'level0'}
>>> g.removeDecisionFromZone('A', 'level0')
True
>>> g.zoneParents(0)
set()
>>> g.removeDecisionFromZone('A', 'level0')
False
>>> g.removeDecisionFromZone('B', 'level0')
True
>>> g.zoneParents(1)
{'level2'}
>>> g.removeDecisionFromZone('B', 'level0')
False
>>> g.removeDecisionFromZone('B', 'level2')
True
>>> g.zoneParents(1)
set()
def addZoneToZone(self, addIt: str, addTo: str) -> None:
2214    def addZoneToZone(
2215        self,
2216        addIt: base.Zone,
2217        addTo: base.Zone
2218    ) -> None:
2219        """
2220        Adds a zone to another zone. The `addIt` one must be at a
2221        strictly lower level than the `addTo` zone, or an
2222        `InvalidLevelError` will be raised.
2223
2224        If the zone to be added didn't already exist, it is created at
2225        one level below the target zone. Similarly, if the zone being
2226        added to didn't already exist, it is created at one level above
2227        the target zone. If neither existed, a `MissingZoneError` will
2228        be raised.
2229
2230        For example:
2231
2232        >>> d = DecisionGraph()
2233        >>> d.addDecision('A')
2234        0
2235        >>> d.addDecision('B')
2236        1
2237        >>> d.addDecision('C')
2238        2
2239        >>> d.createZone('Z', 0)
2240        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2241 annotations=[])
2242        >>> d.addDecisionToZone('A', 'Z')
2243        >>> d.addDecisionToZone('B', 'Z')
2244        >>> d.getZoneInfo('Z')
2245        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2246 annotations=[])
2247        >>> d.createZone('Z2', 0)
2248        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2249 annotations=[])
2250        >>> d.addDecisionToZone('B', 'Z2')
2251        >>> d.addDecisionToZone('C', 'Z2')
2252        >>> d.getZoneInfo('Z2')
2253        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2254 annotations=[])
2255        >>> d.createZone('l1Z', 1)
2256        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2257 annotations=[])
2258        >>> d.createZone('l2Z', 2)
2259        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2260 annotations=[])
2261        >>> d.addZoneToZone('Z', 'l1Z')
2262        >>> d.getZoneInfo('Z')
2263        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
2264 annotations=[])
2265        >>> d.getZoneInfo('l1Z')
2266        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2267 annotations=[])
2268        >>> d.addZoneToZone('l1Z', 'l2Z')
2269        >>> d.getZoneInfo('l1Z')
2270        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2271 annotations=[])
2272        >>> d.getZoneInfo('l2Z')
2273        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2274 annotations=[])
2275        >>> d.addZoneToZone('Z2', 'l2Z')
2276        >>> d.getZoneInfo('Z2')
2277        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
2278 annotations=[])
2279        >>> l2i = d.getZoneInfo('l2Z')
2280        >>> l2i.level
2281        2
2282        >>> l2i.parents
2283        set()
2284        >>> sorted(l2i.contents)
2285        ['Z2', 'l1Z']
2286        >>> d.addZoneToZone('NZ', 'NZ2')
2287        Traceback (most recent call last):
2288        ...
2289        exploration.core.MissingZoneError...
2290        >>> d.addZoneToZone('Z', 'l1Z2')
2291        >>> zi = d.getZoneInfo('Z')
2292        >>> zi.level
2293        0
2294        >>> sorted(zi.parents)
2295        ['l1Z', 'l1Z2']
2296        >>> sorted(zi.contents)
2297        [0, 1]
2298        >>> d.getZoneInfo('l1Z2')
2299        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2300 annotations=[])
2301        >>> d.addZoneToZone('NZ', 'l1Z')
2302        >>> d.getZoneInfo('NZ')
2303        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2304 annotations=[])
2305        >>> zi = d.getZoneInfo('l1Z')
2306        >>> zi.level
2307        1
2308        >>> zi.parents
2309        {'l2Z'}
2310        >>> sorted(zi.contents)
2311        ['NZ', 'Z']
2312        """
2313        # Create one or the other (but not both) if they're missing
2314        addInfo = self.getZoneInfo(addIt)
2315        toInfo = self.getZoneInfo(addTo)
2316        if addInfo is None and toInfo is None:
2317            raise MissingZoneError(
2318                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
2319                f" exists already."
2320            )
2321
2322        # Create missing addIt
2323        elif addInfo is None:
2324            toInfo = cast(base.ZoneInfo, toInfo)
2325            newLevel = toInfo.level - 1
2326            if newLevel < 0:
2327                raise InvalidLevelError(
2328                    f"Zone {addTo!r} is at level {toInfo.level} and so"
2329                    f" a new zone cannot be added underneath it."
2330                )
2331            addInfo = self.createZone(addIt, newLevel)
2332
2333        # Create missing addTo
2334        elif toInfo is None:
2335            addInfo = cast(base.ZoneInfo, addInfo)
2336            newLevel = addInfo.level + 1
2337            if newLevel < 0:
2338                raise InvalidLevelError(
2339                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
2340                    f" and so a new zone cannot be added above it."
2341                )
2342            toInfo = self.createZone(addTo, newLevel)
2343
2344        # Now both addInfo and toInfo are defined
2345        if addInfo.level >= toInfo.level:
2346            raise InvalidLevelError(
2347                f"Cannot add zone {addIt!r} at level {addInfo.level}"
2348                f" to zone {addTo!r} at level {toInfo.level}: zones can"
2349                f" only contain zones of lower levels."
2350            )
2351
2352        # Now both addInfo and toInfo are defined
2353        toInfo.contents.add(addIt)
2354        addInfo.parents.add(addTo)

Adds a zone to another zone. The addIt one must be at a strictly lower level than the addTo zone, or an InvalidLevelError will be raised.

If the zone to be added didn't already exist, it is created at one level below the target zone. Similarly, if the zone being added to didn't already exist, it is created at one level above the target zone. If neither existed, a MissingZoneError will be raised.

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('Z2', 'l2Z')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={}, annotations=[])
>>> l2i = d.getZoneInfo('l2Z')
>>> l2i.level
2
>>> l2i.parents
set()
>>> sorted(l2i.contents)
['Z2', 'l1Z']
>>> d.addZoneToZone('NZ', 'NZ2')
Traceback (most recent call last):
...
MissingZoneError...
>>> d.addZoneToZone('Z', 'l1Z2')
>>> zi = d.getZoneInfo('Z')
>>> zi.level
0
>>> sorted(zi.parents)
['l1Z', 'l1Z2']
>>> sorted(zi.contents)
[0, 1]
>>> d.getZoneInfo('l1Z2')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.addZoneToZone('NZ', 'l1Z')
>>> d.getZoneInfo('NZ')
ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={}, annotations=[])
>>> zi = d.getZoneInfo('l1Z')
>>> zi.level
1
>>> zi.parents
{'l2Z'}
>>> sorted(zi.contents)
['NZ', 'Z']
def removeZoneFromZone(self, removeIt: str, removeFrom: str) -> bool:
2356    def removeZoneFromZone(
2357        self,
2358        removeIt: base.Zone,
2359        removeFrom: base.Zone
2360    ) -> bool:
2361        """
2362        Removes a zone from a zone if it had been in it, returning True
2363        if that zone had been in that zone, and False if it was not in
2364        that zone, including if either zone did not exist.
2365
2366        For example:
2367
2368        >>> d = DecisionGraph()
2369        >>> d.createZone('Z', 0)
2370        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2371 annotations=[])
2372        >>> d.createZone('Z2', 0)
2373        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2374 annotations=[])
2375        >>> d.createZone('l1Z', 1)
2376        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2377 annotations=[])
2378        >>> d.createZone('l2Z', 2)
2379        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2380 annotations=[])
2381        >>> d.addZoneToZone('Z', 'l1Z')
2382        >>> d.addZoneToZone('l1Z', 'l2Z')
2383        >>> d.getZoneInfo('Z')
2384        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2385 annotations=[])
2386        >>> d.getZoneInfo('l1Z')
2387        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2388 annotations=[])
2389        >>> d.getZoneInfo('l2Z')
2390        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2391 annotations=[])
2392        >>> d.removeZoneFromZone('l1Z', 'l2Z')
2393        True
2394        >>> d.getZoneInfo('l1Z')
2395        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2396 annotations=[])
2397        >>> d.getZoneInfo('l2Z')
2398        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2399 annotations=[])
2400        >>> d.removeZoneFromZone('Z', 'l1Z')
2401        True
2402        >>> d.getZoneInfo('Z')
2403        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2404 annotations=[])
2405        >>> d.getZoneInfo('l1Z')
2406        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2407 annotations=[])
2408        >>> d.removeZoneFromZone('Z', 'l1Z')
2409        False
2410        >>> d.removeZoneFromZone('Z', 'madeup')
2411        False
2412        >>> d.removeZoneFromZone('nope', 'madeup')
2413        False
2414        >>> d.removeZoneFromZone('nope', 'l1Z')
2415        False
2416        """
2417        remInfo = self.getZoneInfo(removeIt)
2418        fromInfo = self.getZoneInfo(removeFrom)
2419
2420        if remInfo is None or fromInfo is None:
2421            return False
2422
2423        if removeIt not in fromInfo.contents:
2424            return False
2425
2426        remInfo.parents.remove(removeFrom)
2427        fromInfo.contents.remove(removeIt)
2428        return True

Removes a zone from a zone if it had been in it, returning True if that zone had been in that zone, and False if it was not in that zone, including if either zone did not exist.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={}, annotations=[])
>>> d.removeZoneFromZone('l1Z', 'l2Z')
True
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={}, annotations=[])
>>> d.getZoneInfo('l2Z')
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.removeZoneFromZone('Z', 'l1Z')
True
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.getZoneInfo('l1Z')
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.removeZoneFromZone('Z', 'l1Z')
False
>>> d.removeZoneFromZone('Z', 'madeup')
False
>>> d.removeZoneFromZone('nope', 'madeup')
False
>>> d.removeZoneFromZone('nope', 'l1Z')
False
def decisionsInZone(self, zone: str) -> Set[int]:
2430    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2431        """
2432        Returns a set of all decisions included directly in the given
2433        zone, not counting decisions included via intermediate
2434        sub-zones (see `allDecisionsInZone` to include those).
2435
2436        Raises a `MissingZoneError` if the specified zone does not
2437        exist.
2438
2439        The returned set is a copy, not a live editable set.
2440
2441        For example:
2442
2443        >>> d = DecisionGraph()
2444        >>> d.addDecision('A')
2445        0
2446        >>> d.addDecision('B')
2447        1
2448        >>> d.addDecision('C')
2449        2
2450        >>> d.createZone('Z', 0)
2451        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2452 annotations=[])
2453        >>> d.addDecisionToZone('A', 'Z')
2454        >>> d.addDecisionToZone('B', 'Z')
2455        >>> d.getZoneInfo('Z')
2456        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2457 annotations=[])
2458        >>> d.decisionsInZone('Z')
2459        {0, 1}
2460        >>> d.createZone('Z2', 0)
2461        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2462 annotations=[])
2463        >>> d.addDecisionToZone('B', 'Z2')
2464        >>> d.addDecisionToZone('C', 'Z2')
2465        >>> d.getZoneInfo('Z2')
2466        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2467 annotations=[])
2468        >>> d.decisionsInZone('Z')
2469        {0, 1}
2470        >>> d.decisionsInZone('Z2')
2471        {1, 2}
2472        >>> d.createZone('l1Z', 1)
2473        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2474 annotations=[])
2475        >>> d.addZoneToZone('Z', 'l1Z')
2476        >>> d.decisionsInZone('Z')
2477        {0, 1}
2478        >>> d.decisionsInZone('l1Z')
2479        set()
2480        >>> d.decisionsInZone('madeup')
2481        Traceback (most recent call last):
2482        ...
2483        exploration.core.MissingZoneError...
2484        >>> zDec = d.decisionsInZone('Z')
2485        >>> zDec.add(2)  # won't affect the zone
2486        >>> zDec
2487        {0, 1, 2}
2488        >>> d.decisionsInZone('Z')
2489        {0, 1}
2490        """
2491        info = self.getZoneInfo(zone)
2492        if info is None:
2493            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2494
2495        # Everything that's not a zone must be a decision
2496        return {
2497            item
2498            for item in info.contents
2499            if isinstance(item, base.DecisionID)
2500        }

Returns a set of all decisions included directly in the given zone, not counting decisions included via intermediate sub-zones (see allDecisionsInZone to include those).

Raises a MissingZoneError if the specified zone does not exist.

The returned set is a copy, not a live editable set.

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('l1Z')
set()
>>> d.decisionsInZone('madeup')
Traceback (most recent call last):
...
MissingZoneError...
>>> zDec = d.decisionsInZone('Z')
>>> zDec.add(2)  # won't affect the zone
>>> zDec
{0, 1, 2}
>>> d.decisionsInZone('Z')
{0, 1}
def subZones(self, zone: str) -> Set[str]:
2502    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
2503        """
2504        Returns the set of all immediate sub-zones of the given zone.
2505        Will be an empty set if there are no sub-zones; raises a
2506        `MissingZoneError` if the specified zone does not exit.
2507
2508        The returned set is a copy, not a live editable set.
2509
2510        For example:
2511
2512        >>> d = DecisionGraph()
2513        >>> d.createZone('Z', 0)
2514        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2515 annotations=[])
2516        >>> d.subZones('Z')
2517        set()
2518        >>> d.createZone('l1Z', 1)
2519        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2520 annotations=[])
2521        >>> d.addZoneToZone('Z', 'l1Z')
2522        >>> d.subZones('Z')
2523        set()
2524        >>> d.subZones('l1Z')
2525        {'Z'}
2526        >>> s = d.subZones('l1Z')
2527        >>> s.add('Q')  # doesn't affect the zone
2528        >>> sorted(s)
2529        ['Q', 'Z']
2530        >>> d.subZones('l1Z')
2531        {'Z'}
2532        >>> d.subZones('madeup')
2533        Traceback (most recent call last):
2534        ...
2535        exploration.core.MissingZoneError...
2536        """
2537        info = self.getZoneInfo(zone)
2538        if info is None:
2539            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2540
2541        # Sub-zones will appear in self.zones
2542        return {
2543            item
2544            for item in info.contents
2545            if isinstance(item, base.Zone)
2546        }

Returns the set of all immediate sub-zones of the given zone. Will be an empty set if there are no sub-zones; raises a MissingZoneError if the specified zone does not exit.

The returned set is a copy, not a live editable set.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.subZones('Z')
set()
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.subZones('Z')
set()
>>> d.subZones('l1Z')
{'Z'}
>>> s = d.subZones('l1Z')
>>> s.add('Q')  # doesn't affect the zone
>>> sorted(s)
['Q', 'Z']
>>> d.subZones('l1Z')
{'Z'}
>>> d.subZones('madeup')
Traceback (most recent call last):
...
MissingZoneError...
def allDecisionsInZone(self, zone: str) -> Set[int]:
2548    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2549        """
2550        Returns a set containing all decisions in the given zone,
2551        including those included via sub-zones.
2552
2553        Raises a `MissingZoneError` if the specified zone does not
2554        exist.`
2555
2556        For example:
2557
2558        >>> d = DecisionGraph()
2559        >>> d.addDecision('A')
2560        0
2561        >>> d.addDecision('B')
2562        1
2563        >>> d.addDecision('C')
2564        2
2565        >>> d.createZone('Z', 0)
2566        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2567 annotations=[])
2568        >>> d.addDecisionToZone('A', 'Z')
2569        >>> d.addDecisionToZone('B', 'Z')
2570        >>> d.getZoneInfo('Z')
2571        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2572 annotations=[])
2573        >>> d.decisionsInZone('Z')
2574        {0, 1}
2575        >>> d.allDecisionsInZone('Z')
2576        {0, 1}
2577        >>> d.createZone('Z2', 0)
2578        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2579 annotations=[])
2580        >>> d.addDecisionToZone('B', 'Z2')
2581        >>> d.addDecisionToZone('C', 'Z2')
2582        >>> d.getZoneInfo('Z2')
2583        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2584 annotations=[])
2585        >>> d.decisionsInZone('Z')
2586        {0, 1}
2587        >>> d.decisionsInZone('Z2')
2588        {1, 2}
2589        >>> d.allDecisionsInZone('Z2')
2590        {1, 2}
2591        >>> d.createZone('l1Z', 1)
2592        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2593 annotations=[])
2594        >>> d.createZone('l2Z', 2)
2595        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2596 annotations=[])
2597        >>> d.addZoneToZone('Z', 'l1Z')
2598        >>> d.addZoneToZone('l1Z', 'l2Z')
2599        >>> d.addZoneToZone('Z2', 'l2Z')
2600        >>> d.decisionsInZone('Z')
2601        {0, 1}
2602        >>> d.decisionsInZone('Z2')
2603        {1, 2}
2604        >>> d.decisionsInZone('l1Z')
2605        set()
2606        >>> d.allDecisionsInZone('l1Z')
2607        {0, 1}
2608        >>> d.allDecisionsInZone('l2Z')
2609        {0, 1, 2}
2610        """
2611        result: Set[base.DecisionID] = set()
2612        info = self.getZoneInfo(zone)
2613        if info is None:
2614            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2615
2616        for item in info.contents:
2617            if isinstance(item, base.Zone):
2618                # This can't be an error because of the condition above
2619                result |= self.allDecisionsInZone(item)
2620            else:  # it's a decision
2621                result.add(item)
2622
2623        return result

Returns a set containing all decisions in the given zone, including those included via sub-zones.

Raises a MissingZoneError if the specified zone does not exist.`

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z')
>>> d.addDecisionToZone('B', 'Z')
>>> d.getZoneInfo('Z')
ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.allDecisionsInZone('Z')
{0, 1}
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('B', 'Z2')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.getZoneInfo('Z2')
ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={}, annotations=[])
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.allDecisionsInZone('Z2')
{1, 2}
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l2Z', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addZoneToZone('Z', 'l1Z')
>>> d.addZoneToZone('l1Z', 'l2Z')
>>> d.addZoneToZone('Z2', 'l2Z')
>>> d.decisionsInZone('Z')
{0, 1}
>>> d.decisionsInZone('Z2')
{1, 2}
>>> d.decisionsInZone('l1Z')
set()
>>> d.allDecisionsInZone('l1Z')
{0, 1}
>>> d.allDecisionsInZone('l2Z')
{0, 1, 2}
def zoneHierarchyLevel(self, zone: str) -> int:
2625    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
2626        """
2627        Returns the hierarchy level of the given zone, as stored in its
2628        zone info.
2629
2630        By convention, level-0 zones contain decisions directly, and
2631        higher-level zones contain zones of lower levels. This
2632        convention is not enforced, and there could be exceptions to it.
2633
2634        Raises a `MissingZoneError` if the specified zone does not
2635        exist.
2636
2637        For example:
2638
2639        >>> d = DecisionGraph()
2640        >>> d.createZone('Z', 0)
2641        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2642 annotations=[])
2643        >>> d.createZone('l1Z', 1)
2644        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2645 annotations=[])
2646        >>> d.createZone('l5Z', 5)
2647        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
2648 annotations=[])
2649        >>> d.zoneHierarchyLevel('Z')
2650        0
2651        >>> d.zoneHierarchyLevel('l1Z')
2652        1
2653        >>> d.zoneHierarchyLevel('l5Z')
2654        5
2655        >>> d.zoneHierarchyLevel('madeup')
2656        Traceback (most recent call last):
2657        ...
2658        exploration.core.MissingZoneError...
2659        """
2660        info = self.getZoneInfo(zone)
2661        if info is None:
2662            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
2663
2664        return info.level

Returns the hierarchy level of the given zone, as stored in its zone info.

By convention, level-0 zones contain decisions directly, and higher-level zones contain zones of lower levels. This convention is not enforced, and there could be exceptions to it.

Raises a MissingZoneError if the specified zone does not exist.

For example:

>>> d = DecisionGraph()
>>> d.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l1Z', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('l5Z', 5)
ZoneInfo(level=5, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.zoneHierarchyLevel('Z')
0
>>> d.zoneHierarchyLevel('l1Z')
1
>>> d.zoneHierarchyLevel('l5Z')
5
>>> d.zoneHierarchyLevel('madeup')
Traceback (most recent call last):
...
MissingZoneError...
def zoneParents(self, zoneOrDecision: Union[str, int]) -> Set[str]:
2666    def zoneParents(
2667        self,
2668        zoneOrDecision: Union[base.Zone, base.DecisionID]
2669    ) -> Set[base.Zone]:
2670        """
2671        Returns the set of all zones which directly contain the target
2672        zone or decision.
2673
2674        Raises a `MissingDecisionError` if the target is neither a valid
2675        zone nor a valid decision.
2676
2677        Returns a copy, not a live editable set.
2678
2679        Example:
2680
2681        >>> g = DecisionGraph()
2682        >>> g.addDecision('A')
2683        0
2684        >>> g.addDecision('B')
2685        1
2686        >>> g.createZone('level0', 0)
2687        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2688 annotations=[])
2689        >>> g.createZone('level1', 1)
2690        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2691 annotations=[])
2692        >>> g.createZone('level2', 2)
2693        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2694 annotations=[])
2695        >>> g.createZone('level3', 3)
2696        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2697 annotations=[])
2698        >>> g.addDecisionToZone('A', 'level0')
2699        >>> g.addDecisionToZone('B', 'level0')
2700        >>> g.addZoneToZone('level0', 'level1')
2701        >>> g.addZoneToZone('level1', 'level2')
2702        >>> g.addZoneToZone('level2', 'level3')
2703        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2704        >>> sorted(g.zoneParents(0))
2705        ['level0']
2706        >>> sorted(g.zoneParents(1))
2707        ['level0', 'level2']
2708        """
2709        if zoneOrDecision in self.zones:
2710            zoneOrDecision = cast(base.Zone, zoneOrDecision)
2711            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
2712            return copy.copy(info.parents)
2713        elif zoneOrDecision in self:
2714            return self.nodes[zoneOrDecision].get('zones', set())
2715        else:
2716            raise MissingDecisionError(
2717                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
2718                f" valid decision."
2719            )

Returns the set of all zones which directly contain the target zone or decision.

Raises a MissingDecisionError if the target is neither a valid zone nor a valid decision.

Returns a copy, not a live editable set.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
>>> sorted(g.zoneParents(0))
['level0']
>>> sorted(g.zoneParents(1))
['level0', 'level2']
def zoneAncestors( self, zoneOrDecision: Union[str, int], exclude: Set[str] = set(), atLevel: Optional[int] = None) -> Set[str]:
2721    def zoneAncestors(
2722        self,
2723        zoneOrDecision: Union[base.Zone, base.DecisionID],
2724        exclude: Set[base.Zone] = set(),
2725        atLevel: Optional[int] = None
2726    ) -> Set[base.Zone]:
2727        """
2728        Returns the set of zones which contain the target zone or
2729        decision, either directly or indirectly. The target is not
2730        included in the set.
2731
2732        Any ones listed in the `exclude` set are also excluded, as are
2733        any of their ancestors which are not also ancestors of the
2734        target zone via another path of inclusion.
2735
2736        If `atLevel` is not `None`, then only zones at that hierarchy
2737        level will be included.
2738
2739        Raises a `MissingDecisionError` if the target is nether a valid
2740        zone nor a valid decision.
2741
2742        Example:
2743
2744        >>> g = DecisionGraph()
2745        >>> g.addDecision('A')
2746        0
2747        >>> g.addDecision('B')
2748        1
2749        >>> g.createZone('level0', 0)
2750        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2751 annotations=[])
2752        >>> g.createZone('level1', 1)
2753        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2754 annotations=[])
2755        >>> g.createZone('level2', 2)
2756        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2757 annotations=[])
2758        >>> g.createZone('level3', 3)
2759        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2760 annotations=[])
2761        >>> g.addDecisionToZone('A', 'level0')
2762        >>> g.addDecisionToZone('B', 'level0')
2763        >>> g.addZoneToZone('level0', 'level1')
2764        >>> g.addZoneToZone('level1', 'level2')
2765        >>> g.addZoneToZone('level2', 'level3')
2766        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2767        >>> sorted(g.zoneAncestors(0))
2768        ['level0', 'level1', 'level2', 'level3']
2769        >>> sorted(g.zoneAncestors(1))
2770        ['level0', 'level1', 'level2', 'level3']
2771        >>> sorted(g.zoneParents(0))
2772        ['level0']
2773        >>> sorted(g.zoneParents(1))
2774        ['level0', 'level2']
2775        >>> sorted(g.zoneAncestors(0, atLevel=2))
2776        ['level2']
2777        >>> sorted(g.zoneAncestors(0, exclude={'level2'}))
2778        ['level0', 'level1']
2779        """
2780        # Copy is important here!
2781        result = set(self.zoneParents(zoneOrDecision))
2782        result -= exclude
2783        for parent in copy.copy(result):
2784            # Recursively dig up ancestors, but exclude
2785            # results-so-far to avoid re-enumerating when there are
2786            # multiple braided inclusion paths.
2787            result |= self.zoneAncestors(parent, result | exclude, atLevel)
2788
2789        if atLevel is not None:
2790            return {z for z in result if self.zoneHierarchyLevel(z) == atLevel}
2791        else:
2792            return result

Returns the set of zones which contain the target zone or decision, either directly or indirectly. The target is not included in the set.

Any ones listed in the exclude set are also excluded, as are any of their ancestors which are not also ancestors of the target zone via another path of inclusion.

If atLevel is not None, then only zones at that hierarchy level will be included.

Raises a MissingDecisionError if the target is nether a valid zone nor a valid decision.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'level0')
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
>>> sorted(g.zoneAncestors(0))
['level0', 'level1', 'level2', 'level3']
>>> sorted(g.zoneAncestors(1))
['level0', 'level1', 'level2', 'level3']
>>> sorted(g.zoneParents(0))
['level0']
>>> sorted(g.zoneParents(1))
['level0', 'level2']
>>> sorted(g.zoneAncestors(0, atLevel=2))
['level2']
>>> sorted(g.zoneAncestors(0, exclude={'level2'}))
['level0', 'level1']
def region(self, decision: int, useLevel: int = 1) -> Optional[str]:
2794    def region(
2795        self,
2796        decision: base.DecisionID,
2797        useLevel: int=1
2798    ) -> Optional[base.Zone]:
2799        """
2800        Returns the 'region' that this decision belongs to. 'Regions'
2801        are level-1 zones, but when a decision is in multiple level-1
2802        zones, its region counts as the smallest of those zones in terms
2803        of total decisions contained, breaking ties by the one with the
2804        alphabetically earlier name.
2805
2806        Always returns a single zone name string, unless the target
2807        decision is not in any level-1 zones, in which case it returns
2808        `None`.
2809
2810        If `useLevel` is specified, then zones of the specified level
2811        will be used instead of level-1 zones.
2812
2813        Example:
2814
2815        >>> g = DecisionGraph()
2816        >>> g.addDecision('A')
2817        0
2818        >>> g.addDecision('B')
2819        1
2820        >>> g.addDecision('C')
2821        2
2822        >>> g.addDecision('D')
2823        3
2824        >>> g.createZone('zoneX', 0)
2825        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2826 annotations=[])
2827        >>> g.createZone('regionA', 1)
2828        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2829 annotations=[])
2830        >>> g.createZone('zoneY', 0)
2831        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2832 annotations=[])
2833        >>> g.createZone('regionB', 1)
2834        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2835 annotations=[])
2836        >>> g.createZone('regionC', 1)
2837        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2838 annotations=[])
2839        >>> g.createZone('quadrant', 2)
2840        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2841 annotations=[])
2842        >>> g.addDecisionToZone('A', 'zoneX')
2843        >>> g.addDecisionToZone('B', 'zoneY')
2844        >>> # C is not in any level-1 zones
2845        >>> g.addDecisionToZone('D', 'zoneX')
2846        >>> g.addDecisionToZone('D', 'zoneY')  # D is in both
2847        >>> g.addZoneToZone('zoneX', 'regionA')
2848        >>> g.addZoneToZone('zoneY', 'regionB')
2849        >>> g.addZoneToZone('zoneX', 'regionC')  # includes both
2850        >>> g.addZoneToZone('zoneY', 'regionC')
2851        >>> g.addZoneToZone('regionA', 'quadrant')
2852        >>> g.addZoneToZone('regionB', 'quadrant')
2853        >>> g.addDecisionToZone('C', 'regionC')  # Direct in level-2
2854        >>> sorted(g.allDecisionsInZone('zoneX'))
2855        [0, 3]
2856        >>> sorted(g.allDecisionsInZone('zoneY'))
2857        [1, 3]
2858        >>> sorted(g.allDecisionsInZone('regionA'))
2859        [0, 3]
2860        >>> sorted(g.allDecisionsInZone('regionB'))
2861        [1, 3]
2862        >>> sorted(g.allDecisionsInZone('regionC'))
2863        [0, 1, 2, 3]
2864        >>> sorted(g.allDecisionsInZone('quadrant'))
2865        [0, 1, 3]
2866        >>> g.region(0)  # for A; region A is smaller than region C
2867        'regionA'
2868        >>> g.region(1)  # for B; region B is also smaller than C
2869        'regionB'
2870        >>> g.region(2)  # for C
2871        'regionC'
2872        >>> g.region(3)  # for D; tie broken alphabetically
2873        'regionA'
2874        >>> g.region(0, useLevel=0)  # for A at level 0
2875        'zoneX'
2876        >>> g.region(1, useLevel=0)  # for B at level 0
2877        'zoneY'
2878        >>> g.region(2, useLevel=0) is None  # for C at level 0 (none)
2879        True
2880        >>> g.region(3, useLevel=0)  # for D at level 0; tie
2881        'zoneX'
2882        >>> g.region(0, useLevel=2) # for A at level 2
2883        'quadrant'
2884        >>> g.region(1, useLevel=2) # for B at level 2
2885        'quadrant'
2886        >>> g.region(2, useLevel=2) is None # for C at level 2 (none)
2887        True
2888        >>> g.region(3, useLevel=2)  # for D at level 2
2889        'quadrant'
2890        """
2891        relevant = self.zoneAncestors(decision, atLevel=useLevel)
2892        if len(relevant) == 0:
2893            return None
2894        elif len(relevant) == 1:
2895            for zone in relevant:
2896                return zone
2897            return None  # not really necessary but keeps mypy happy
2898        else:
2899            # more than one zone ancestor at the relevant hierarchy
2900            # level: need to measure their sizes
2901            minSize = None
2902            candidates = []
2903            for zone in relevant:
2904                size = len(self.allDecisionsInZone(zone))
2905                if minSize is None or size < minSize:
2906                    candidates = [zone]
2907                    minSize = size
2908                elif size == minSize:
2909                    candidates.append(zone)
2910            return min(candidates)

Returns the 'region' that this decision belongs to. 'Regions' are level-1 zones, but when a decision is in multiple level-1 zones, its region counts as the smallest of those zones in terms of total decisions contained, breaking ties by the one with the alphabetically earlier name.

Always returns a single zone name string, unless the target decision is not in any level-1 zones, in which case it returns None.

If useLevel is specified, then zones of the specified level will be used instead of level-1 zones.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('D')
3
>>> g.createZone('zoneX', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('regionA', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zoneY', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('regionB', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('regionC', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('quadrant', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('A', 'zoneX')
>>> g.addDecisionToZone('B', 'zoneY')
>>> # C is not in any level-1 zones
>>> g.addDecisionToZone('D', 'zoneX')
>>> g.addDecisionToZone('D', 'zoneY')  # D is in both
>>> g.addZoneToZone('zoneX', 'regionA')
>>> g.addZoneToZone('zoneY', 'regionB')
>>> g.addZoneToZone('zoneX', 'regionC')  # includes both
>>> g.addZoneToZone('zoneY', 'regionC')
>>> g.addZoneToZone('regionA', 'quadrant')
>>> g.addZoneToZone('regionB', 'quadrant')
>>> g.addDecisionToZone('C', 'regionC')  # Direct in level-2
>>> sorted(g.allDecisionsInZone('zoneX'))
[0, 3]
>>> sorted(g.allDecisionsInZone('zoneY'))
[1, 3]
>>> sorted(g.allDecisionsInZone('regionA'))
[0, 3]
>>> sorted(g.allDecisionsInZone('regionB'))
[1, 3]
>>> sorted(g.allDecisionsInZone('regionC'))
[0, 1, 2, 3]
>>> sorted(g.allDecisionsInZone('quadrant'))
[0, 1, 3]
>>> g.region(0)  # for A; region A is smaller than region C
'regionA'
>>> g.region(1)  # for B; region B is also smaller than C
'regionB'
>>> g.region(2)  # for C
'regionC'
>>> g.region(3)  # for D; tie broken alphabetically
'regionA'
>>> g.region(0, useLevel=0)  # for A at level 0
'zoneX'
>>> g.region(1, useLevel=0)  # for B at level 0
'zoneY'
>>> g.region(2, useLevel=0) is None  # for C at level 0 (none)
True
>>> g.region(3, useLevel=0)  # for D at level 0; tie
'zoneX'
>>> g.region(0, useLevel=2) # for A at level 2
'quadrant'
>>> g.region(1, useLevel=2) # for B at level 2
'quadrant'
>>> g.region(2, useLevel=2) is None # for C at level 2 (none)
True
>>> g.region(3, useLevel=2)  # for D at level 2
'quadrant'
def zoneEdges( self, zone: str) -> Optional[Tuple[Set[Tuple[int, str]], Set[Tuple[int, str]]]]:
2912    def zoneEdges(self, zone: base.Zone) -> Optional[
2913        Tuple[
2914            Set[Tuple[base.DecisionID, base.Transition]],
2915            Set[Tuple[base.DecisionID, base.Transition]]
2916        ]
2917    ]:
2918        """
2919        Given a zone to look at, finds all of the transitions which go
2920        out of and into that zone, ignoring internal transitions between
2921        decisions in the zone. This includes all decisions in sub-zones.
2922        The return value is a pair of sets for outgoing and then
2923        incoming transitions, where each transition is specified as a
2924        (sourceID, transitionName) pair.
2925
2926        Returns `None` if the target zone isn't yet fully defined.
2927
2928        Note that this takes time proportional to *all* edges plus *all*
2929        nodes in the graph no matter how large or small the zone in
2930        question is.
2931
2932        >>> g = DecisionGraph()
2933        >>> g.addDecision('A')
2934        0
2935        >>> g.addDecision('B')
2936        1
2937        >>> g.addDecision('C')
2938        2
2939        >>> g.addDecision('D')
2940        3
2941        >>> g.addTransition('A', 'up', 'B', 'down')
2942        >>> g.addTransition('B', 'right', 'C', 'left')
2943        >>> g.addTransition('C', 'down', 'D', 'up')
2944        >>> g.addTransition('D', 'left', 'A', 'right')
2945        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
2946        >>> g.createZone('Z', 0)
2947        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2948 annotations=[])
2949        >>> g.createZone('ZZ', 1)
2950        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2951 annotations=[])
2952        >>> g.addZoneToZone('Z', 'ZZ')
2953        >>> g.addDecisionToZone('A', 'Z')
2954        >>> g.addDecisionToZone('B', 'Z')
2955        >>> g.addDecisionToZone('D', 'ZZ')
2956        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
2957        >>> sorted(outgoing)
2958        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
2959        >>> sorted(incoming)
2960        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
2961        >>> outgoing, incoming = g.zoneEdges('ZZ')
2962        >>> sorted(outgoing)
2963        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
2964        >>> sorted(incoming)
2965        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
2966        >>> g.zoneEdges('madeup') is None
2967        True
2968        """
2969        # Find the interior nodes
2970        try:
2971            interior = self.allDecisionsInZone(zone)
2972        except MissingZoneError:
2973            return None
2974
2975        # Set up our result
2976        results: Tuple[
2977            Set[Tuple[base.DecisionID, base.Transition]],
2978            Set[Tuple[base.DecisionID, base.Transition]]
2979        ] = (set(), set())
2980
2981        # Because finding incoming edges requires searching the entire
2982        # graph anyways, it's more efficient to just consider each edge
2983        # once.
2984        for fromDecision in self:
2985            fromThere = self[fromDecision]
2986            for toDecision in fromThere:
2987                for transition in fromThere[toDecision]:
2988                    sourceIn = fromDecision in interior
2989                    destIn = toDecision in interior
2990                    if sourceIn and not destIn:
2991                        results[0].add((fromDecision, transition))
2992                    elif destIn and not sourceIn:
2993                        results[1].add((fromDecision, transition))
2994
2995        return results

Given a zone to look at, finds all of the transitions which go out of and into that zone, ignoring internal transitions between decisions in the zone. This includes all decisions in sub-zones. The return value is a pair of sets for outgoing and then incoming transitions, where each transition is specified as a (sourceID, transitionName) pair.

Returns None if the target zone isn't yet fully defined.

Note that this takes time proportional to all edges plus all nodes in the graph no matter how large or small the zone in question is.

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addDecision('D')
3
>>> g.addTransition('A', 'up', 'B', 'down')
>>> g.addTransition('B', 'right', 'C', 'left')
>>> g.addTransition('C', 'down', 'D', 'up')
>>> g.addTransition('D', 'left', 'A', 'right')
>>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('ZZ', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addZoneToZone('Z', 'ZZ')
>>> g.addDecisionToZone('A', 'Z')
>>> g.addDecisionToZone('B', 'Z')
>>> g.addDecisionToZone('D', 'ZZ')
>>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
>>> sorted(outgoing)
[(0, 'right'), (0, 'tunnel'), (1, 'right')]
>>> sorted(incoming)
[(2, 'left'), (2, 'tunnel'), (3, 'left')]
>>> outgoing, incoming = g.zoneEdges('ZZ')
>>> sorted(outgoing)
[(0, 'tunnel'), (1, 'right'), (3, 'up')]
>>> sorted(incoming)
[(2, 'down'), (2, 'left'), (2, 'tunnel')]
>>> g.zoneEdges('madeup') is None
True
def replaceZonesInHierarchy( self, target: Union[int, exploration.base.DecisionSpecifier, str], zone: str, level: int) -> None:
2997    def replaceZonesInHierarchy(
2998        self,
2999        target: base.AnyDecisionSpecifier,
3000        zone: base.Zone,
3001        level: int
3002    ) -> None:
3003        """
3004        This method replaces one or more zones which contain the
3005        specified `target` decision with a specific zone, at a specific
3006        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
3007        named zone doesn't yet exist, it will be created.
3008
3009        To do this, it looks at all zones which contain the target
3010        decision directly or indirectly (see `zoneAncestors`) and which
3011        are at the specified level.
3012
3013        - Any direct children of those zones which are ancestors of the
3014            target decision are removed from those zones and placed into
3015            the new zone instead, regardless of their levels. Indirect
3016            children are not affected (except perhaps indirectly via
3017            their parents' ancestors changing).
3018        - The new zone is placed into every direct parent of those
3019            zones, regardless of their levels (those parents are by
3020            definition all ancestors of the target decision).
3021        - If there were no zones at the target level, every zone at the
3022            next level down which is an ancestor of the target decision
3023            (or just that decision if the level is 0) is placed into the
3024            new zone as a direct child (and is removed from any previous
3025            parents it had). In this case, the new zone will also be
3026            added as a sub-zone to every ancestor of the target decision
3027            at the level above the specified level, if there are any.
3028            * In this case, if there are no zones at the level below the
3029                specified level, the highest level of zones smaller than
3030                that is treated as the level below, down to targeting
3031                the decision itself.
3032            * Similarly, if there are no zones at the level above the
3033                specified level but there are zones at a higher level,
3034                the new zone will be added to each of the zones in the
3035                lowest level above the target level that has zones in it.
3036
3037        A `MissingDecisionError` will be raised if the specified
3038        decision is not valid, or if the decision is left as default but
3039        there is no current decision in the exploration.
3040
3041        An `InvalidLevelError` will be raised if the level is less than
3042        zero.
3043
3044        Example:
3045
3046        >>> g = DecisionGraph()
3047        >>> g.addDecision('decision')
3048        0
3049        >>> g.addDecision('alternate')
3050        1
3051        >>> g.createZone('zone0', 0)
3052        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3053 annotations=[])
3054        >>> g.createZone('zone1', 1)
3055        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3056 annotations=[])
3057        >>> g.createZone('zone2.1', 2)
3058        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3059 annotations=[])
3060        >>> g.createZone('zone2.2', 2)
3061        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3062 annotations=[])
3063        >>> g.createZone('zone3', 3)
3064        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3065 annotations=[])
3066        >>> g.addDecisionToZone('decision', 'zone0')
3067        >>> g.addDecisionToZone('alternate', 'zone0')
3068        >>> g.addZoneToZone('zone0', 'zone1')
3069        >>> g.addZoneToZone('zone1', 'zone2.1')
3070        >>> g.addZoneToZone('zone1', 'zone2.2')
3071        >>> g.addZoneToZone('zone2.1', 'zone3')
3072        >>> g.addZoneToZone('zone2.2', 'zone3')
3073        >>> g.zoneHierarchyLevel('zone0')
3074        0
3075        >>> g.zoneHierarchyLevel('zone1')
3076        1
3077        >>> g.zoneHierarchyLevel('zone2.1')
3078        2
3079        >>> g.zoneHierarchyLevel('zone2.2')
3080        2
3081        >>> g.zoneHierarchyLevel('zone3')
3082        3
3083        >>> sorted(g.decisionsInZone('zone0'))
3084        [0, 1]
3085        >>> sorted(g.zoneAncestors('zone0'))
3086        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3087        >>> g.subZones('zone1')
3088        {'zone0'}
3089        >>> g.zoneParents('zone0')
3090        {'zone1'}
3091        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
3092        >>> g.zoneParents('zone0')
3093        {'zone1'}
3094        >>> g.zoneParents('new0')
3095        {'zone1'}
3096        >>> sorted(g.zoneAncestors('zone0'))
3097        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3098        >>> sorted(g.zoneAncestors('new0'))
3099        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
3100        >>> g.decisionsInZone('zone0')
3101        {1}
3102        >>> g.decisionsInZone('new0')
3103        {0}
3104        >>> sorted(g.subZones('zone1'))
3105        ['new0', 'zone0']
3106        >>> g.zoneParents('new0')
3107        {'zone1'}
3108        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
3109        >>> sorted(g.zoneAncestors(0))
3110        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
3111        >>> g.subZones('zone1')
3112        {'zone0'}
3113        >>> g.subZones('new1')
3114        {'new0'}
3115        >>> g.zoneParents('new0')
3116        {'new1'}
3117        >>> sorted(g.zoneParents('zone1'))
3118        ['zone2.1', 'zone2.2']
3119        >>> sorted(g.zoneParents('new1'))
3120        ['zone2.1', 'zone2.2']
3121        >>> g.zoneParents('zone2.1')
3122        {'zone3'}
3123        >>> g.zoneParents('zone2.2')
3124        {'zone3'}
3125        >>> sorted(g.subZones('zone2.1'))
3126        ['new1', 'zone1']
3127        >>> sorted(g.subZones('zone2.2'))
3128        ['new1', 'zone1']
3129        >>> sorted(g.allDecisionsInZone('zone2.1'))
3130        [0, 1]
3131        >>> sorted(g.allDecisionsInZone('zone2.2'))
3132        [0, 1]
3133        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
3134        >>> g.zoneParents('zone2.1')
3135        {'zone3'}
3136        >>> g.zoneParents('zone2.2')
3137        {'zone3'}
3138        >>> g.subZones('zone2.1')
3139        {'zone1'}
3140        >>> g.subZones('zone2.2')
3141        {'zone1'}
3142        >>> g.subZones('new2')
3143        {'new1'}
3144        >>> g.zoneParents('new2')
3145        {'zone3'}
3146        >>> g.allDecisionsInZone('zone2.1')
3147        {1}
3148        >>> g.allDecisionsInZone('zone2.2')
3149        {1}
3150        >>> g.allDecisionsInZone('new2')
3151        {0}
3152        >>> sorted(g.subZones('zone3'))
3153        ['new2', 'zone2.1', 'zone2.2']
3154        >>> g.zoneParents('zone3')
3155        set()
3156        >>> sorted(g.allDecisionsInZone('zone3'))
3157        [0, 1]
3158        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
3159        >>> sorted(g.subZones('zone3'))
3160        ['zone2.1', 'zone2.2']
3161        >>> g.subZones('new3')
3162        {'new2'}
3163        >>> g.zoneParents('zone3')
3164        set()
3165        >>> g.zoneParents('new3')
3166        set()
3167        >>> g.allDecisionsInZone('zone3')
3168        {1}
3169        >>> g.allDecisionsInZone('new3')
3170        {0}
3171        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
3172        >>> g.subZones('new4')
3173        {'new3'}
3174        >>> g.zoneHierarchyLevel('new4')
3175        5
3176
3177        Another example of level collapse when trying to replace a zone
3178        at a level above :
3179
3180        >>> g = DecisionGraph()
3181        >>> g.addDecision('A')
3182        0
3183        >>> g.addDecision('B')
3184        1
3185        >>> g.createZone('level0', 0)
3186        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3187 annotations=[])
3188        >>> g.createZone('level1', 1)
3189        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3190 annotations=[])
3191        >>> g.createZone('level2', 2)
3192        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3193 annotations=[])
3194        >>> g.createZone('level3', 3)
3195        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3196 annotations=[])
3197        >>> g.addDecisionToZone('B', 'level0')
3198        >>> g.addZoneToZone('level0', 'level1')
3199        >>> g.addZoneToZone('level1', 'level2')
3200        >>> g.addZoneToZone('level2', 'level3')
3201        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
3202        >>> g.zoneHierarchyLevel('level3')
3203        3
3204        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
3205        >>> g.zoneHierarchyLevel('newFirst')
3206        1
3207        >>> g.decisionsInZone('newFirst')
3208        {0}
3209        >>> g.decisionsInZone('level3')
3210        set()
3211        >>> sorted(g.allDecisionsInZone('level3'))
3212        [0, 1]
3213        >>> g.subZones('newFirst')
3214        set()
3215        >>> sorted(g.subZones('level3'))
3216        ['level2', 'newFirst']
3217        >>> g.zoneParents('newFirst')
3218        {'level3'}
3219        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
3220        >>> g.zoneHierarchyLevel('newSecond')
3221        2
3222        >>> g.decisionsInZone('newSecond')
3223        set()
3224        >>> g.allDecisionsInZone('newSecond')
3225        {0}
3226        >>> g.subZones('newSecond')
3227        {'newFirst'}
3228        >>> g.zoneParents('newSecond')
3229        {'level3'}
3230        >>> g.zoneParents('newFirst')
3231        {'newSecond'}
3232        >>> sorted(g.subZones('level3'))
3233        ['level2', 'newSecond']
3234        """
3235        tID = self.resolveDecision(target)
3236
3237        if level < 0:
3238            raise InvalidLevelError(
3239                f"Target level must be positive (got {level})."
3240            )
3241
3242        info = self.getZoneInfo(zone)
3243        if info is None:
3244            info = self.createZone(zone, level)
3245        elif level != info.level:
3246            raise InvalidLevelError(
3247                f"Target level ({level}) does not match the level of"
3248                f" the target zone ({zone!r} at level {info.level})."
3249            )
3250
3251        # Collect both parents & ancestors
3252        parents = self.zoneParents(tID)
3253        ancestors = set(self.zoneAncestors(tID))
3254
3255        # Map from levels to sets of zones from the ancestors pool
3256        levelMap: Dict[int, Set[base.Zone]] = {}
3257        highest = -1
3258        for ancestor in ancestors:
3259            ancestorLevel = self.zoneHierarchyLevel(ancestor)
3260            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
3261            if ancestorLevel > highest:
3262                highest = ancestorLevel
3263
3264        # Figure out if we have target zones to replace or not
3265        reparentDecision = False
3266        if level in levelMap:
3267            # If there are zones at the target level,
3268            targetZones = levelMap[level]
3269
3270            above = set()
3271            below = set()
3272
3273            for replaced in targetZones:
3274                above |= self.zoneParents(replaced)
3275                below |= self.subZones(replaced)
3276                if replaced in parents:
3277                    reparentDecision = True
3278
3279            # Only ancestors should be reparented
3280            below &= ancestors
3281
3282        else:
3283            # Find levels w/ zones in them above + below
3284            levelBelow = level - 1
3285            levelAbove = level + 1
3286            below = levelMap.get(levelBelow, set())
3287            above = levelMap.get(levelAbove, set())
3288
3289            while len(below) == 0 and levelBelow > 0:
3290                levelBelow -= 1
3291                below = levelMap.get(levelBelow, set())
3292
3293            if len(below) == 0:
3294                reparentDecision = True
3295
3296            while len(above) == 0 and levelAbove < highest:
3297                levelAbove += 1
3298                above = levelMap.get(levelAbove, set())
3299
3300        # Handle re-parenting zones below
3301        for under in below:
3302            for parent in self.zoneParents(under):
3303                if parent in ancestors:
3304                    self.removeZoneFromZone(under, parent)
3305            self.addZoneToZone(under, zone)
3306
3307        # Add this zone to each parent
3308        for parent in above:
3309            self.addZoneToZone(zone, parent)
3310
3311        # Re-parent the decision itself if necessary
3312        if reparentDecision:
3313            # (using set() here to avoid size-change-during-iteration)
3314            for parent in set(parents):
3315                self.removeDecisionFromZone(tID, parent)
3316            self.addDecisionToZone(tID, zone)

This method replaces one or more zones which contain the specified target decision with a specific zone, at a specific level in the zone hierarchy (see zoneHierarchyLevel). If the named zone doesn't yet exist, it will be created.

To do this, it looks at all zones which contain the target decision directly or indirectly (see zoneAncestors) and which are at the specified level.

  • Any direct children of those zones which are ancestors of the target decision are removed from those zones and placed into the new zone instead, regardless of their levels. Indirect children are not affected (except perhaps indirectly via their parents' ancestors changing).
  • The new zone is placed into every direct parent of those zones, regardless of their levels (those parents are by definition all ancestors of the target decision).
  • If there were no zones at the target level, every zone at the next level down which is an ancestor of the target decision (or just that decision if the level is 0) is placed into the new zone as a direct child (and is removed from any previous parents it had). In this case, the new zone will also be added as a sub-zone to every ancestor of the target decision at the level above the specified level, if there are any.
    • In this case, if there are no zones at the level below the specified level, the highest level of zones smaller than that is treated as the level below, down to targeting the decision itself.
    • Similarly, if there are no zones at the level above the specified level but there are zones at a higher level, the new zone will be added to each of the zones in the lowest level above the target level that has zones in it.

A MissingDecisionError will be raised if the specified decision is not valid, or if the decision is left as default but there is no current decision in the exploration.

An InvalidLevelError will be raised if the level is less than zero.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('decision')
0
>>> g.addDecision('alternate')
1
>>> g.createZone('zone0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2.1', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone2.2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('zone3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('decision', 'zone0')
>>> g.addDecisionToZone('alternate', 'zone0')
>>> g.addZoneToZone('zone0', 'zone1')
>>> g.addZoneToZone('zone1', 'zone2.1')
>>> g.addZoneToZone('zone1', 'zone2.2')
>>> g.addZoneToZone('zone2.1', 'zone3')
>>> g.addZoneToZone('zone2.2', 'zone3')
>>> g.zoneHierarchyLevel('zone0')
0
>>> g.zoneHierarchyLevel('zone1')
1
>>> g.zoneHierarchyLevel('zone2.1')
2
>>> g.zoneHierarchyLevel('zone2.2')
2
>>> g.zoneHierarchyLevel('zone3')
3
>>> sorted(g.decisionsInZone('zone0'))
[0, 1]
>>> sorted(g.zoneAncestors('zone0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.subZones('zone1')
{'zone0'}
>>> g.zoneParents('zone0')
{'zone1'}
>>> g.replaceZonesInHierarchy('decision', 'new0', 0)
>>> g.zoneParents('zone0')
{'zone1'}
>>> g.zoneParents('new0')
{'zone1'}
>>> sorted(g.zoneAncestors('zone0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> sorted(g.zoneAncestors('new0'))
['zone1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.decisionsInZone('zone0')
{1}
>>> g.decisionsInZone('new0')
{0}
>>> sorted(g.subZones('zone1'))
['new0', 'zone0']
>>> g.zoneParents('new0')
{'zone1'}
>>> g.replaceZonesInHierarchy('decision', 'new1', 1)
>>> sorted(g.zoneAncestors(0))
['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
>>> g.subZones('zone1')
{'zone0'}
>>> g.subZones('new1')
{'new0'}
>>> g.zoneParents('new0')
{'new1'}
>>> sorted(g.zoneParents('zone1'))
['zone2.1', 'zone2.2']
>>> sorted(g.zoneParents('new1'))
['zone2.1', 'zone2.2']
>>> g.zoneParents('zone2.1')
{'zone3'}
>>> g.zoneParents('zone2.2')
{'zone3'}
>>> sorted(g.subZones('zone2.1'))
['new1', 'zone1']
>>> sorted(g.subZones('zone2.2'))
['new1', 'zone1']
>>> sorted(g.allDecisionsInZone('zone2.1'))
[0, 1]
>>> sorted(g.allDecisionsInZone('zone2.2'))
[0, 1]
>>> g.replaceZonesInHierarchy('decision', 'new2', 2)
>>> g.zoneParents('zone2.1')
{'zone3'}
>>> g.zoneParents('zone2.2')
{'zone3'}
>>> g.subZones('zone2.1')
{'zone1'}
>>> g.subZones('zone2.2')
{'zone1'}
>>> g.subZones('new2')
{'new1'}
>>> g.zoneParents('new2')
{'zone3'}
>>> g.allDecisionsInZone('zone2.1')
{1}
>>> g.allDecisionsInZone('zone2.2')
{1}
>>> g.allDecisionsInZone('new2')
{0}
>>> sorted(g.subZones('zone3'))
['new2', 'zone2.1', 'zone2.2']
>>> g.zoneParents('zone3')
set()
>>> sorted(g.allDecisionsInZone('zone3'))
[0, 1]
>>> g.replaceZonesInHierarchy('decision', 'new3', 3)
>>> sorted(g.subZones('zone3'))
['zone2.1', 'zone2.2']
>>> g.subZones('new3')
{'new2'}
>>> g.zoneParents('zone3')
set()
>>> g.zoneParents('new3')
set()
>>> g.allDecisionsInZone('zone3')
{1}
>>> g.allDecisionsInZone('new3')
{0}
>>> g.replaceZonesInHierarchy('decision', 'new4', 5)
>>> g.subZones('new4')
{'new3'}
>>> g.zoneHierarchyLevel('new4')
5

Another example of level collapse when trying to replace a zone at a level above :

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.createZone('level0', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level1', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level2', 2)
ZoneInfo(level=2, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.createZone('level3', 3)
ZoneInfo(level=3, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('B', 'level0')
>>> g.addZoneToZone('level0', 'level1')
>>> g.addZoneToZone('level1', 'level2')
>>> g.addZoneToZone('level2', 'level3')
>>> g.addDecisionToZone('A', 'level3') # missing some zone levels
>>> g.zoneHierarchyLevel('level3')
3
>>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
>>> g.zoneHierarchyLevel('newFirst')
1
>>> g.decisionsInZone('newFirst')
{0}
>>> g.decisionsInZone('level3')
set()
>>> sorted(g.allDecisionsInZone('level3'))
[0, 1]
>>> g.subZones('newFirst')
set()
>>> sorted(g.subZones('level3'))
['level2', 'newFirst']
>>> g.zoneParents('newFirst')
{'level3'}
>>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
>>> g.zoneHierarchyLevel('newSecond')
2
>>> g.decisionsInZone('newSecond')
set()
>>> g.allDecisionsInZone('newSecond')
{0}
>>> g.subZones('newSecond')
{'newFirst'}
>>> g.zoneParents('newSecond')
{'level3'}
>>> g.zoneParents('newFirst')
{'newSecond'}
>>> sorted(g.subZones('level3'))
['level2', 'newSecond']
def getReciprocal( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> Optional[str]:
3318    def getReciprocal(
3319        self,
3320        decision: base.AnyDecisionSpecifier,
3321        transition: base.Transition
3322    ) -> Optional[base.Transition]:
3323        """
3324        Returns the reciprocal edge for the specified transition from the
3325        specified decision (see `setReciprocal`). Returns
3326        `None` if no reciprocal has been established for that
3327        transition, or if that decision or transition does not exist.
3328        """
3329        dID = self.resolveDecision(decision)
3330
3331        dest = self.getDestination(dID, transition)
3332        if dest is not None:
3333            info = cast(
3334                TransitionProperties,
3335                self.edges[dID, dest, transition]  # type:ignore
3336            )
3337            recip = info.get("reciprocal")
3338            if recip is not None and not isinstance(recip, base.Transition):
3339                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
3340            return recip
3341        else:
3342            return None

Returns the reciprocal edge for the specified transition from the specified decision (see setReciprocal). Returns None if no reciprocal has been established for that transition, or if that decision or transition does not exist.

def setReciprocal( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, reciprocal: Optional[str], setBoth: bool = True, cleanup: bool = True) -> None:
3344    def setReciprocal(
3345        self,
3346        decision: base.AnyDecisionSpecifier,
3347        transition: base.Transition,
3348        reciprocal: Optional[base.Transition],
3349        setBoth: bool = True,
3350        cleanup: bool = True
3351    ) -> None:
3352        """
3353        Sets the 'reciprocal' transition for a particular transition from
3354        a particular decision, and removes the reciprocal property from
3355        any old reciprocal transition.
3356
3357        Raises a `MissingDecisionError` or a `MissingTransitionError` if
3358        the specified decision or transition does not exist.
3359
3360        Raises an `InvalidDestinationError` if the reciprocal transition
3361        does not exist, or if it does exist but does not lead back to
3362        the decision the transition came from.
3363
3364        If `setBoth` is True (the default) then the transition which is
3365        being identified as a reciprocal will also have its reciprocal
3366        property set, pointing back to the primary transition being
3367        modified, and any old reciprocal of that transition will have its
3368        reciprocal set to None. If you want to create a situation with
3369        non-exclusive reciprocals, use `setBoth=False`.
3370
3371        If `cleanup` is True (the default) then abandoned reciprocal
3372        transitions (for both edges if `setBoth` was true) have their
3373        reciprocal properties removed. Set `cleanup` to false if you want
3374        to retain them, although this will result in non-exclusive
3375        reciprocal relationships.
3376
3377        If the `reciprocal` value is None, this deletes the reciprocal
3378        value entirely, and if `setBoth` is true, it does this for the
3379        previous reciprocal edge as well. No error is raised in this case
3380        when there was not already a reciprocal to delete.
3381
3382        Note that one should remove a reciprocal relationship before
3383        redirecting either edge of the pair in a way that gives it a new
3384        reciprocal, since otherwise, a later attempt to remove the
3385        reciprocal with `setBoth` set to True (the default) will end up
3386        deleting the reciprocal information from the other edge that was
3387        already modified. There is no way to reliably detect and avoid
3388        this, because two different decisions could (and often do in
3389        practice) have transitions with identical names, meaning that the
3390        reciprocal value will still be the same, but it will indicate a
3391        different edge in virtue of the destination of the edge changing.
3392
3393        ## Example
3394
3395        >>> g = DecisionGraph()
3396        >>> g.addDecision('G')
3397        0
3398        >>> g.addDecision('H')
3399        1
3400        >>> g.addDecision('I')
3401        2
3402        >>> g.addTransition('G', 'up', 'H', 'down')
3403        >>> g.addTransition('G', 'next', 'H', 'prev')
3404        >>> g.addTransition('H', 'next', 'I', 'prev')
3405        >>> g.addTransition('H', 'return', 'G')
3406        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
3407        Traceback (most recent call last):
3408        ...
3409        exploration.core.InvalidDestinationError...
3410        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
3411        Traceback (most recent call last):
3412        ...
3413        exploration.core.MissingTransitionError...
3414        >>> g.getReciprocal('G', 'up')
3415        'down'
3416        >>> g.getReciprocal('H', 'down')
3417        'up'
3418        >>> g.getReciprocal('H', 'return') is None
3419        True
3420        >>> g.setReciprocal('G', 'up', 'return')
3421        >>> g.getReciprocal('G', 'up')
3422        'return'
3423        >>> g.getReciprocal('H', 'down') is None
3424        True
3425        >>> g.getReciprocal('H', 'return')
3426        'up'
3427        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
3428        >>> g.getReciprocal('G', 'up') is None
3429        True
3430        >>> g.getReciprocal('H', 'down') is None
3431        True
3432        >>> g.getReciprocal('H', 'return') is None
3433        True
3434        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
3435        >>> g.getReciprocal('G', 'up')
3436        'down'
3437        >>> g.getReciprocal('H', 'down') is None
3438        True
3439        >>> g.getReciprocal('H', 'return') is None
3440        True
3441        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
3442        >>> g.getReciprocal('G', 'up')
3443        'down'
3444        >>> g.getReciprocal('H', 'down') is None
3445        True
3446        >>> g.getReciprocal('H', 'return')
3447        'up'
3448        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
3449        >>> g.getReciprocal('G', 'up')
3450        'down'
3451        >>> g.getReciprocal('H', 'down')
3452        'up'
3453        >>> g.getReciprocal('H', 'return') # unchanged
3454        'up'
3455        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
3456        >>> g.getReciprocal('G', 'up')
3457        'return'
3458        >>> g.getReciprocal('H', 'down')
3459        'up'
3460        >>> g.getReciprocal('H', 'return') # unchanged
3461        'up'
3462        >>> # Cleanup only applies to reciprocal if setBoth is true
3463        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
3464        >>> g.getReciprocal('G', 'up')
3465        'return'
3466        >>> g.getReciprocal('H', 'down')
3467        'up'
3468        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
3469        'up'
3470        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
3471        >>> g.getReciprocal('G', 'up')
3472        'down'
3473        >>> g.getReciprocal('H', 'down')
3474        'up'
3475        >>> g.getReciprocal('H', 'return') is None # cleaned up
3476        True
3477        """
3478        dID = self.resolveDecision(decision)
3479
3480        dest = self.destination(dID, transition) # possible KeyError
3481        if reciprocal is None:
3482            rDest = None
3483        else:
3484            rDest = self.getDestination(dest, reciprocal)
3485
3486        # Set or delete reciprocal property
3487        if reciprocal is None:
3488            # Delete the property
3489            info = self.edges[dID, dest, transition]  # type:ignore
3490
3491            old = info.pop('reciprocal')
3492            if setBoth:
3493                rDest = self.getDestination(dest, old)
3494                if rDest != dID:
3495                    raise RuntimeError(
3496                        f"Invalid reciprocal {old!r} for transition"
3497                        f" {transition!r} from {self.identityOf(dID)}:"
3498                        f" destination is {rDest}."
3499                    )
3500                rInfo = self.edges[dest, dID, old]  # type:ignore
3501                if 'reciprocal' in rInfo:
3502                    del rInfo['reciprocal']
3503        else:
3504            # Set the property, checking for errors first
3505            if rDest is None:
3506                raise MissingTransitionError(
3507                    f"Reciprocal transition {reciprocal!r} for"
3508                    f" transition {transition!r} from decision"
3509                    f" {self.identityOf(dID)} does not exist at"
3510                    f" decision {self.identityOf(dest)}"
3511                )
3512
3513            if rDest != dID:
3514                raise InvalidDestinationError(
3515                    f"Reciprocal transition {reciprocal!r} from"
3516                    f" decision {self.identityOf(dest)} does not lead"
3517                    f" back to decision {self.identityOf(dID)}."
3518                )
3519
3520            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
3521            abandoned = eProps.get('reciprocal')
3522            eProps['reciprocal'] = reciprocal
3523            if cleanup and abandoned not in (None, reciprocal):
3524                aProps = self.edges[dest, dID, abandoned]  # type:ignore
3525                if 'reciprocal' in aProps:
3526                    del aProps['reciprocal']
3527
3528            if setBoth:
3529                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
3530                revAbandoned = rProps.get('reciprocal')
3531                rProps['reciprocal'] = transition
3532                # Sever old reciprocal relationship
3533                if cleanup and revAbandoned not in (None, transition):
3534                    raProps = self.edges[
3535                        dID,  # type:ignore
3536                        dest,
3537                        revAbandoned
3538                    ]
3539                    del raProps['reciprocal']

Sets the 'reciprocal' transition for a particular transition from a particular decision, and removes the reciprocal property from any old reciprocal transition.

Raises a MissingDecisionError or a MissingTransitionError if the specified decision or transition does not exist.

Raises an InvalidDestinationError if the reciprocal transition does not exist, or if it does exist but does not lead back to the decision the transition came from.

If setBoth is True (the default) then the transition which is being identified as a reciprocal will also have its reciprocal property set, pointing back to the primary transition being modified, and any old reciprocal of that transition will have its reciprocal set to None. If you want to create a situation with non-exclusive reciprocals, use setBoth=False.

If cleanup is True (the default) then abandoned reciprocal transitions (for both edges if setBoth was true) have their reciprocal properties removed. Set cleanup to false if you want to retain them, although this will result in non-exclusive reciprocal relationships.

If the reciprocal value is None, this deletes the reciprocal value entirely, and if setBoth is true, it does this for the previous reciprocal edge as well. No error is raised in this case when there was not already a reciprocal to delete.

Note that one should remove a reciprocal relationship before redirecting either edge of the pair in a way that gives it a new reciprocal, since otherwise, a later attempt to remove the reciprocal with setBoth set to True (the default) will end up deleting the reciprocal information from the other edge that was already modified. There is no way to reliably detect and avoid this, because two different decisions could (and often do in practice) have transitions with identical names, meaning that the reciprocal value will still be the same, but it will indicate a different edge in virtue of the destination of the edge changing.

Example

>>> g = DecisionGraph()
>>> g.addDecision('G')
0
>>> g.addDecision('H')
1
>>> g.addDecision('I')
2
>>> g.addTransition('G', 'up', 'H', 'down')
>>> g.addTransition('G', 'next', 'H', 'prev')
>>> g.addTransition('H', 'next', 'I', 'prev')
>>> g.addTransition('H', 'return', 'G')
>>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
Traceback (most recent call last):
...
InvalidDestinationError...
>>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('G', 'up', 'return')
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return')
'up'
>>> g.setReciprocal('H', 'return', None) # remove the reciprocal
>>> g.getReciprocal('G', 'up') is None
True
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return') is None
True
>>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down') is None
True
>>> g.getReciprocal('H', 'return')
'up'
>>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # unchanged
'up'
>>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # unchanged
'up'
>>> # Cleanup only applies to reciprocal if setBoth is true
>>> g.setReciprocal('H', 'down', 'up', setBoth=False)
>>> g.getReciprocal('G', 'up')
'return'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
'up'
>>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
>>> g.getReciprocal('G', 'up')
'down'
>>> g.getReciprocal('H', 'down')
'up'
>>> g.getReciprocal('H', 'return') is None # cleaned up
True
def getReciprocalPair( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str) -> Optional[Tuple[int, str]]:
3541    def getReciprocalPair(
3542        self,
3543        decision: base.AnyDecisionSpecifier,
3544        transition: base.Transition
3545    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
3546        """
3547        Returns a tuple containing both the destination decision ID and
3548        the transition at that decision which is the reciprocal of the
3549        specified destination & transition. Returns `None` if no
3550        reciprocal has been established for that transition, or if that
3551        decision or transition does not exist.
3552
3553        >>> g = DecisionGraph()
3554        >>> g.addDecision('A')
3555        0
3556        >>> g.addDecision('B')
3557        1
3558        >>> g.addDecision('C')
3559        2
3560        >>> g.addTransition('A', 'up', 'B', 'down')
3561        >>> g.addTransition('B', 'right', 'C', 'left')
3562        >>> g.addTransition('A', 'oneway', 'C')
3563        >>> g.getReciprocalPair('A', 'up')
3564        (1, 'down')
3565        >>> g.getReciprocalPair('B', 'down')
3566        (0, 'up')
3567        >>> g.getReciprocalPair('B', 'right')
3568        (2, 'left')
3569        >>> g.getReciprocalPair('C', 'left')
3570        (1, 'right')
3571        >>> g.getReciprocalPair('C', 'up') is None
3572        True
3573        >>> g.getReciprocalPair('Q', 'up') is None
3574        True
3575        >>> g.getReciprocalPair('A', 'tunnel') is None
3576        True
3577        """
3578        try:
3579            dID = self.resolveDecision(decision)
3580        except MissingDecisionError:
3581            return None
3582
3583        reciprocal = self.getReciprocal(dID, transition)
3584        if reciprocal is None:
3585            return None
3586        else:
3587            destination = self.getDestination(dID, transition)
3588            if destination is None:
3589                return None
3590            else:
3591                return (destination, reciprocal)

Returns a tuple containing both the destination decision ID and the transition at that decision which is the reciprocal of the specified destination & transition. Returns None if no reciprocal has been established for that transition, or if that decision or transition does not exist.

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'up', 'B', 'down')
>>> g.addTransition('B', 'right', 'C', 'left')
>>> g.addTransition('A', 'oneway', 'C')
>>> g.getReciprocalPair('A', 'up')
(1, 'down')
>>> g.getReciprocalPair('B', 'down')
(0, 'up')
>>> g.getReciprocalPair('B', 'right')
(2, 'left')
>>> g.getReciprocalPair('C', 'left')
(1, 'right')
>>> g.getReciprocalPair('C', 'up') is None
True
>>> g.getReciprocalPair('Q', 'up') is None
True
>>> g.getReciprocalPair('A', 'tunnel') is None
True
def addDecision( self, name: str, domain: Optional[str] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None) -> int:
3593    def addDecision(
3594        self,
3595        name: base.DecisionName,
3596        domain: Optional[base.Domain] = None,
3597        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3598        annotations: Optional[List[base.Annotation]] = None
3599    ) -> base.DecisionID:
3600        """
3601        Adds a decision to the graph, without any transitions yet. Each
3602        decision will be assigned an ID so name collisions are allowed,
3603        but it's usually best to keep names unique at least within each
3604        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
3605        used for the decision's domain. A dictionary of tags and/or a
3606        list of annotations (strings in both cases) may be provided.
3607
3608        Returns the newly-assigned `DecisionID` for the decision it
3609        created.
3610
3611        Emits a `DecisionCollisionWarning` if a decision with the
3612        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
3613        global variable is set to `True`.
3614        """
3615        # Defaults
3616        if domain is None:
3617            domain = base.DEFAULT_DOMAIN
3618        if tags is None:
3619            tags = {}
3620        if annotations is None:
3621            annotations = []
3622
3623        # Error checking
3624        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3625            warnings.warn(
3626                (
3627                    f"Adding decision {name!r}: Another decision with"
3628                    f" that name already exists."
3629                ),
3630                DecisionCollisionWarning
3631            )
3632
3633        dID = self._assignID()
3634
3635        # Add the decision
3636        self.add_node(
3637            dID,
3638            name=name,
3639            domain=domain,
3640            tags=tags,
3641            annotations=annotations
3642        )
3643        #TODO: Elide tags/annotations if they're empty?
3644
3645        # Track it in our `nameLookup` dictionary
3646        self.nameLookup.setdefault(name, []).append(dID)
3647
3648        return dID

Adds a decision to the graph, without any transitions yet. Each decision will be assigned an ID so name collisions are allowed, but it's usually best to keep names unique at least within each zone. If no domain is provided, the DEFAULT_DOMAIN will be used for the decision's domain. A dictionary of tags and/or a list of annotations (strings in both cases) may be provided.

Returns the newly-assigned DecisionID for the decision it created.

Emits a DecisionCollisionWarning if a decision with the provided name already exists and the WARN_OF_NAME_COLLISIONS global variable is set to True.

def addIdentifiedDecision( self, dID: int, name: str, domain: Optional[str] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None) -> None:
3650    def addIdentifiedDecision(
3651        self,
3652        dID: base.DecisionID,
3653        name: base.DecisionName,
3654        domain: Optional[base.Domain] = None,
3655        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3656        annotations: Optional[List[base.Annotation]] = None
3657    ) -> None:
3658        """
3659        Adds a new decision to the graph using a specific decision ID,
3660        rather than automatically assigning a new decision ID like
3661        `addDecision` does. Otherwise works like `addDecision`.
3662
3663        Raises a `MechanismCollisionError` if the specified decision ID
3664        is already in use.
3665        """
3666        # Defaults
3667        if domain is None:
3668            domain = base.DEFAULT_DOMAIN
3669        if tags is None:
3670            tags = {}
3671        if annotations is None:
3672            annotations = []
3673
3674        # Error checking
3675        if dID in self.nodes:
3676            raise MechanismCollisionError(
3677                f"Cannot add a node with id {dID} and name {name!r}:"
3678                f" that ID is already used by node {self.identityOf(dID)}"
3679            )
3680
3681        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3682            warnings.warn(
3683                (
3684                    f"Adding decision {name!r}: Another decision with"
3685                    f" that name already exists."
3686                ),
3687                DecisionCollisionWarning
3688            )
3689
3690        # Add the decision
3691        self.add_node(
3692            dID,
3693            name=name,
3694            domain=domain,
3695            tags=tags,
3696            annotations=annotations
3697        )
3698        #TODO: Elide tags/annotations if they're empty?
3699
3700        # Track it in our `nameLookup` dictionary
3701        self.nameLookup.setdefault(name, []).append(dID)

Adds a new decision to the graph using a specific decision ID, rather than automatically assigning a new decision ID like addDecision does. Otherwise works like addDecision.

Raises a MechanismCollisionError if the specified decision ID is already in use.

def addTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], name: str, toDecision: Union[int, exploration.base.DecisionSpecifier, str], reciprocal: Optional[str] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None, revTags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, revAnnotations: Optional[List[str]] = None, requires: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, revRequires: Optional[exploration.base.Requirement] = None, revConsequece: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None) -> None:
3703    def addTransition(
3704        self,
3705        fromDecision: base.AnyDecisionSpecifier,
3706        name: base.Transition,
3707        toDecision: base.AnyDecisionSpecifier,
3708        reciprocal: Optional[base.Transition] = None,
3709        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3710        annotations: Optional[List[base.Annotation]] = None,
3711        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
3712        revAnnotations: Optional[List[base.Annotation]] = None,
3713        requires: Optional[base.Requirement] = None,
3714        consequence: Optional[base.Consequence] = None,
3715        revRequires: Optional[base.Requirement] = None,
3716        revConsequece: Optional[base.Consequence] = None
3717    ) -> None:
3718        """
3719        Adds a transition connecting two decisions. A specifier for each
3720        decision is required, as is a name for the transition. If a
3721        `reciprocal` is provided, a reciprocal edge will be added in the
3722        opposite direction using that name; by default only the specified
3723        edge is added. A `TransitionCollisionError` will be raised if the
3724        `reciprocal` matches the name of an existing edge at the
3725        destination decision.
3726
3727        Both decisions must already exist, or a `MissingDecisionError`
3728        will be raised.
3729
3730        A dictionary of tags and/or a list of annotations may be
3731        provided. Tags and/or annotations for the reverse edge may also
3732        be specified if one is being added.
3733
3734        The `requires`, `consequence`, `revRequires`, and `revConsequece`
3735        arguments specify requirements and/or consequences of the new
3736        outgoing and reciprocal edges.
3737        """
3738        # Defaults
3739        if tags is None:
3740            tags = {}
3741        if annotations is None:
3742            annotations = []
3743        if revTags is None:
3744            revTags = {}
3745        if revAnnotations is None:
3746            revAnnotations = []
3747
3748        # Error checking
3749        fromID = self.resolveDecision(fromDecision)
3750        toID = self.resolveDecision(toDecision)
3751
3752        # Note: have to check this first so we don't add the forward edge
3753        # and then error out after a side effect!
3754        if (
3755            reciprocal is not None
3756        and self.getDestination(toDecision, reciprocal) is not None
3757        ):
3758            raise TransitionCollisionError(
3759                f"Cannot add a transition from"
3760                f" {self.identityOf(fromDecision)} to"
3761                f" {self.identityOf(toDecision)} with reciprocal edge"
3762                f" {reciprocal!r}: {reciprocal!r} is already used as an"
3763                f" edge name at {self.identityOf(toDecision)}."
3764            )
3765
3766        # Add the edge
3767        self.add_edge(
3768            fromID,
3769            toID,
3770            key=name,
3771            tags=tags,
3772            annotations=annotations
3773        )
3774        self.setTransitionRequirement(fromDecision, name, requires)
3775        if consequence is not None:
3776            self.setConsequence(fromDecision, name, consequence)
3777        if reciprocal is not None:
3778            # Add the reciprocal edge
3779            self.add_edge(
3780                toID,
3781                fromID,
3782                key=reciprocal,
3783                tags=revTags,
3784                annotations=revAnnotations
3785            )
3786            self.setReciprocal(fromID, name, reciprocal)
3787            self.setTransitionRequirement(
3788                toDecision,
3789                reciprocal,
3790                revRequires
3791            )
3792            if revConsequece is not None:
3793                self.setConsequence(toDecision, reciprocal, revConsequece)

Adds a transition connecting two decisions. A specifier for each decision is required, as is a name for the transition. If a reciprocal is provided, a reciprocal edge will be added in the opposite direction using that name; by default only the specified edge is added. A TransitionCollisionError will be raised if the reciprocal matches the name of an existing edge at the destination decision.

Both decisions must already exist, or a MissingDecisionError will be raised.

A dictionary of tags and/or a list of annotations may be provided. Tags and/or annotations for the reverse edge may also be specified if one is being added.

The requires, consequence, revRequires, and revConsequece arguments specify requirements and/or consequences of the new outgoing and reciprocal edges.

def removeTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, removeReciprocal=False) -> Union[TransitionProperties, Tuple[TransitionProperties, TransitionProperties]]:
3795    def removeTransition(
3796        self,
3797        fromDecision: base.AnyDecisionSpecifier,
3798        transition: base.Transition,
3799        removeReciprocal=False
3800    ) -> Union[
3801        TransitionProperties,
3802        Tuple[TransitionProperties, TransitionProperties]
3803    ]:
3804        """
3805        Removes a transition. If `removeReciprocal` is true (False is the
3806        default) any reciprocal transition will also be removed (but no
3807        error will occur if there wasn't a reciprocal).
3808
3809        For each removed transition, *every* transition that targeted
3810        that transition as its reciprocal will have its reciprocal set to
3811        `None`, to avoid leaving any invalid reciprocal values.
3812
3813        Raises a `KeyError` if either the target decision or the target
3814        transition does not exist.
3815
3816        Returns a transition properties dictionary with the properties
3817        of the removed transition, or if `removeReciprocal` is true,
3818        returns a pair of such dictionaries for the target transition
3819        and its reciprocal.
3820
3821        ## Example
3822
3823        >>> g = DecisionGraph()
3824        >>> g.addDecision('A')
3825        0
3826        >>> g.addDecision('B')
3827        1
3828        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
3829        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
3830        >>> g.addTransition('A', 'next', 'B')
3831        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
3832        >>> p = g.removeTransition('A', 'up')
3833        >>> p['tags']
3834        {'wide'}
3835        >>> g.destinationsFrom('A')
3836        {'in': 1, 'next': 1}
3837        >>> g.destinationsFrom('B')
3838        {'down': 0, 'out': 0}
3839        >>> g.getReciprocal('B', 'down') is None
3840        True
3841        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
3842        'down'
3843        >>> g.getReciprocal('A', 'in') # not affected
3844        'out'
3845        >>> g.getReciprocal('B', 'out') # not affected
3846        'in'
3847        >>> # Now with removeReciprocal set to True
3848        >>> g.addTransition('A', 'up', 'B') # add this back in
3849        >>> g.setReciprocal('A', 'up', 'down') # sets both
3850        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
3851        >>> g.destinationsFrom('A')
3852        {'in': 1, 'next': 1}
3853        >>> g.destinationsFrom('B')
3854        {'out': 0}
3855        >>> g.getReciprocal('A', 'next') is None
3856        True
3857        >>> g.getReciprocal('A', 'in') # not affected
3858        'out'
3859        >>> g.getReciprocal('B', 'out') # not affected
3860        'in'
3861        >>> g.removeTransition('A', 'none')
3862        Traceback (most recent call last):
3863        ...
3864        exploration.core.MissingTransitionError...
3865        >>> g.removeTransition('Z', 'nope')
3866        Traceback (most recent call last):
3867        ...
3868        exploration.core.MissingDecisionError...
3869        """
3870        # Resolve target ID
3871        fromID = self.resolveDecision(fromDecision)
3872
3873        # raises if either is missing:
3874        destination = self.destination(fromID, transition)
3875        reciprocal = self.getReciprocal(fromID, transition)
3876
3877        # Get dictionaries of parallel & antiparallel edges to be
3878        # checked for invalid reciprocals after removing edges
3879        # Note: these will update live as we remove edges
3880        allAntiparallel = self[destination][fromID]
3881        allParallel = self[fromID][destination]
3882
3883        # Remove the target edge
3884        fProps = self.getTransitionProperties(fromID, transition)
3885        self.remove_edge(fromID, destination, transition)
3886
3887        # Clean up any dangling reciprocal values
3888        for tProps in allAntiparallel.values():
3889            if tProps.get('reciprocal') == transition:
3890                del tProps['reciprocal']
3891
3892        # Remove the reciprocal if requested
3893        if removeReciprocal and reciprocal is not None:
3894            rProps = self.getTransitionProperties(destination, reciprocal)
3895            self.remove_edge(destination, fromID, reciprocal)
3896
3897            # Clean up any dangling reciprocal values
3898            for tProps in allParallel.values():
3899                if tProps.get('reciprocal') == reciprocal:
3900                    del tProps['reciprocal']
3901
3902            return (fProps, rProps)
3903        else:
3904            return fProps

Removes a transition. If removeReciprocal is true (False is the default) any reciprocal transition will also be removed (but no error will occur if there wasn't a reciprocal).

For each removed transition, every transition that targeted that transition as its reciprocal will have its reciprocal set to None, to avoid leaving any invalid reciprocal values.

Raises a KeyError if either the target decision or the target transition does not exist.

Returns a transition properties dictionary with the properties of the removed transition, or if removeReciprocal is true, returns a pair of such dictionaries for the target transition and its reciprocal.

Example

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
>>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
>>> g.addTransition('A', 'next', 'B')
>>> g.setReciprocal('A', 'next', 'down', setBoth=False)
>>> p = g.removeTransition('A', 'up')
>>> p['tags']
{'wide'}
>>> g.destinationsFrom('A')
{'in': 1, 'next': 1}
>>> g.destinationsFrom('B')
{'down': 0, 'out': 0}
>>> g.getReciprocal('B', 'down') is None
True
>>> g.getReciprocal('A', 'next') # Asymmetrical left over
'down'
>>> g.getReciprocal('A', 'in') # not affected
'out'
>>> g.getReciprocal('B', 'out') # not affected
'in'
>>> # Now with removeReciprocal set to True
>>> g.addTransition('A', 'up', 'B') # add this back in
>>> g.setReciprocal('A', 'up', 'down') # sets both
>>> p = g.removeTransition('A', 'up', removeReciprocal=True)
>>> g.destinationsFrom('A')
{'in': 1, 'next': 1}
>>> g.destinationsFrom('B')
{'out': 0}
>>> g.getReciprocal('A', 'next') is None
True
>>> g.getReciprocal('A', 'in') # not affected
'out'
>>> g.getReciprocal('B', 'out') # not affected
'in'
>>> g.removeTransition('A', 'none')
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.removeTransition('Z', 'nope')
Traceback (most recent call last):
...
MissingDecisionError...
def addMechanism( self, name: str, where: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None) -> int:
3906    def addMechanism(
3907        self,
3908        name: base.MechanismName,
3909        where: Optional[base.AnyDecisionSpecifier] = None
3910    ) -> base.MechanismID:
3911        """
3912        Creates a new mechanism with the given name at the specified
3913        decision, returning its assigned ID. If `where` is `None`, it
3914        creates a global mechanism. Raises a `MechanismCollisionError`
3915        if a mechanism with the same name already exists at a specified
3916        decision (or already exists as a global mechanism).
3917
3918        Note that if the decision is deleted, the mechanism will be as
3919        well.
3920
3921        Since `MechanismState`s are not tracked by `DecisionGraph`s but
3922        instead are part of a `State`, the mechanism won't be in any
3923        particular state, which means it will be treated as being in the
3924        `base.DEFAULT_MECHANISM_STATE`.
3925        """
3926        if where is None:
3927            mechs = self.globalMechanisms
3928            dID = None
3929        else:
3930            dID = self.resolveDecision(where)
3931            mechs = self.nodes[dID].setdefault('mechanisms', {})
3932
3933        if name in mechs:
3934            if dID is None:
3935                raise MechanismCollisionError(
3936                    f"A global mechanism named {name!r} already exists."
3937                )
3938            else:
3939                raise MechanismCollisionError(
3940                    f"A mechanism named {name!r} already exists at"
3941                    f" decision {self.identityOf(dID)}."
3942                )
3943
3944        mID = self._assignMechanismID()
3945        mechs[name] = mID
3946        self.mechanisms[mID] = (dID, name)
3947        return mID

Creates a new mechanism with the given name at the specified decision, returning its assigned ID. If where is None, it creates a global mechanism. Raises a MechanismCollisionError if a mechanism with the same name already exists at a specified decision (or already exists as a global mechanism).

Note that if the decision is deleted, the mechanism will be as well.

Since MechanismStates are not tracked by DecisionGraphs but instead are part of a State, the mechanism won't be in any particular state, which means it will be treated as being in the base.DEFAULT_MECHANISM_STATE.

def mechanismsAt( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> Dict[str, int]:
3949    def mechanismsAt(
3950        self,
3951        decision: base.AnyDecisionSpecifier
3952    ) -> Dict[base.MechanismName, base.MechanismID]:
3953        """
3954        Returns a dictionary mapping mechanism names to their IDs for
3955        all mechanisms at the specified decision.
3956        """
3957        dID = self.resolveDecision(decision)
3958
3959        return self.nodes[dID]['mechanisms']

Returns a dictionary mapping mechanism names to their IDs for all mechanisms at the specified decision.

def mechanismDetails(self, mID: int) -> Optional[Tuple[Optional[int], str]]:
3961    def mechanismDetails(
3962        self,
3963        mID: base.MechanismID
3964    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
3965        """
3966        Returns a tuple containing the decision ID and mechanism name
3967        for the specified mechanism. Returns `None` if there is no
3968        mechanism with that ID. For global mechanisms, `None` is used in
3969        place of a decision ID.
3970        """
3971        return self.mechanisms.get(mID)

Returns a tuple containing the decision ID and mechanism name for the specified mechanism. Returns None if there is no mechanism with that ID. For global mechanisms, None is used in place of a decision ID.

def deleteMechanism(self, mID: int) -> None:
3973    def deleteMechanism(self, mID: base.MechanismID) -> None:
3974        """
3975        Deletes the specified mechanism.
3976        """
3977        name, dID = self.mechanisms.pop(mID)
3978
3979        del self.nodes[dID]['mechanisms'][name]

Deletes the specified mechanism.

def localLookup( self, startFrom: Union[int, exploration.base.DecisionSpecifier, str, Collection[Union[int, exploration.base.DecisionSpecifier, str]]], findAmong: Callable[[DecisionGraph, Union[Set[int], str]], Optional[~LookupResult]], fallbackLayerName: Optional[str] = 'fallback', fallbackToAllDecisions: bool = True) -> Optional[~LookupResult]:
3981    def localLookup(
3982        self,
3983        startFrom: Union[
3984            base.AnyDecisionSpecifier,
3985            Collection[base.AnyDecisionSpecifier]
3986        ],
3987        findAmong: Callable[
3988            ['DecisionGraph', Union[Set[base.DecisionID], str]],
3989            Optional[LookupResult]
3990        ],
3991        fallbackLayerName: Optional[str] = "fallback",
3992        fallbackToAllDecisions: bool = True
3993    ) -> Optional[LookupResult]:
3994        """
3995        Looks up some kind of result in the graph by starting from a
3996        base set of decisions and widening the search iteratively based
3997        on zones. This first searches for result(s) in the set of
3998        decisions given, then in the set of all decisions which are in
3999        level-0 zones containing those decisions, then in level-1 zones,
4000        etc. When it runs out of relevant zones, it will check all
4001        decisions which are in any domain that a decision from the
4002        initial search set is in, and then if `fallbackLayerName` is a
4003        string, it will provide that string instead of a set of decision
4004        IDs to the `findAmong` function as the next layer to search.
4005        After the `fallbackLayerName` is used, if
4006        `fallbackToAllDecisions` is `True` (the default) a final search
4007        will be run on all decisions in the graph. The provided
4008        `findAmong` function is called on each successive decision ID
4009        set, until it generates a non-`None` result. We stop and return
4010        that non-`None` result as soon as one is generated. But if none
4011        of the decision sets consulted generate non-`None` results, then
4012        the entire result will be `None`.
4013        """
4014        # Normalize starting decisions to a set
4015        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
4016            startFrom = set([startFrom])
4017
4018        # Resolve decision IDs; convert to list
4019        searchArea: Union[Set[base.DecisionID], str] = set(
4020            self.resolveDecision(spec) for spec in startFrom
4021        )
4022
4023        # Find all ancestor zones & all relevant domains
4024        allAncestors = set()
4025        relevantDomains = set()
4026        for startingDecision in searchArea:
4027            allAncestors |= self.zoneAncestors(startingDecision)
4028            relevantDomains.add(self.domainFor(startingDecision))
4029
4030        # Build layers dictionary
4031        ancestorLayers: Dict[int, Set[base.Zone]] = {}
4032        for zone in allAncestors:
4033            info = self.getZoneInfo(zone)
4034            assert info is not None
4035            level = info.level
4036            ancestorLayers.setdefault(level, set()).add(zone)
4037
4038        searchLayers: LookupLayersList = (
4039            cast(LookupLayersList, [None])
4040          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
4041          + cast(LookupLayersList, ["domains"])
4042        )
4043        if fallbackLayerName is not None:
4044            searchLayers.append("fallback")
4045
4046        if fallbackToAllDecisions:
4047            searchLayers.append("all")
4048
4049        # Continue our search through zone layers
4050        for layer in searchLayers:
4051            # Update search area on subsequent iterations
4052            if layer == "domains":
4053                searchArea = set()
4054                for relevant in relevantDomains:
4055                    searchArea |= self.allDecisionsInDomain(relevant)
4056            elif layer == "fallback":
4057                assert fallbackLayerName is not None
4058                searchArea = fallbackLayerName
4059            elif layer == "all":
4060                searchArea = set(self.nodes)
4061            elif layer is not None:
4062                layer = cast(int, layer)  # must be an integer
4063                searchZones = ancestorLayers[layer]
4064                searchArea = set()
4065                for zone in searchZones:
4066                    searchArea |= self.allDecisionsInZone(zone)
4067            # else it's the first iteration and we use the starting
4068            # searchArea
4069
4070            searchResult: Optional[LookupResult] = findAmong(
4071                self,
4072                searchArea
4073            )
4074
4075            if searchResult is not None:
4076                return searchResult
4077
4078        # Didn't find any non-None results.
4079        return None

Looks up some kind of result in the graph by starting from a base set of decisions and widening the search iteratively based on zones. This first searches for result(s) in the set of decisions given, then in the set of all decisions which are in level-0 zones containing those decisions, then in level-1 zones, etc. When it runs out of relevant zones, it will check all decisions which are in any domain that a decision from the initial search set is in, and then if fallbackLayerName is a string, it will provide that string instead of a set of decision IDs to the findAmong function as the next layer to search. After the fallbackLayerName is used, if fallbackToAllDecisions is True (the default) a final search will be run on all decisions in the graph. The provided findAmong function is called on each successive decision ID set, until it generates a non-None result. We stop and return that non-None result as soon as one is generated. But if none of the decision sets consulted generate non-None results, then the entire result will be None.

@staticmethod
def uniqueMechanismFinder( name: str) -> Callable[[DecisionGraph, Union[Set[int], str]], Optional[int]]:
4081    @staticmethod
4082    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
4083        ['DecisionGraph', Union[Set[base.DecisionID], str]],
4084        Optional[base.MechanismID]
4085    ]:
4086        """
4087        Returns a search function that looks for the given mechanism ID,
4088        suitable for use with `localLookup`. The finder will raise a
4089        `MechanismCollisionError` if it finds more than one mechanism
4090        with the specified name at the same level of the search.
4091        """
4092        def namedMechanismFinder(
4093            graph: 'DecisionGraph',
4094            searchIn: Union[Set[base.DecisionID], str]
4095        ) -> Optional[base.MechanismID]:
4096            """
4097            Generated finder function for `localLookup` to find a unique
4098            mechanism by name.
4099            """
4100            candidates: List[base.DecisionID] = []
4101
4102            if searchIn == "fallback":
4103                if name in graph.globalMechanisms:
4104                    candidates = [graph.globalMechanisms[name]]
4105
4106            else:
4107                assert isinstance(searchIn, set)
4108                for dID in searchIn:
4109                    mechs = graph.nodes[dID].get('mechanisms', {})
4110                    if name in mechs:
4111                        candidates.append(mechs[name])
4112
4113            if len(candidates) > 1:
4114                raise MechanismCollisionError(
4115                    f"There are {len(candidates)} mechanisms named {name!r}"
4116                    f" in the search area ({len(searchIn)} decisions(s))."
4117                )
4118            elif len(candidates) == 1:
4119                return candidates[0]
4120            else:
4121                return None
4122
4123        return namedMechanismFinder

Returns a search function that looks for the given mechanism ID, suitable for use with localLookup. The finder will raise a MechanismCollisionError if it finds more than one mechanism with the specified name at the same level of the search.

def lookupMechanism( self, startFrom: Union[int, exploration.base.DecisionSpecifier, str, Collection[Union[int, exploration.base.DecisionSpecifier, str]]], name: str) -> int:
4125    def lookupMechanism(
4126        self,
4127        startFrom: Union[
4128            base.AnyDecisionSpecifier,
4129            Collection[base.AnyDecisionSpecifier]
4130        ],
4131        name: base.MechanismName
4132    ) -> base.MechanismID:
4133        """
4134        Looks up the mechanism with the given name 'closest' to the
4135        given decision or set of decisions. First it looks for a
4136        mechanism with that name that's at one of those decisions. Then
4137        it starts looking in level-0 zones which contain any of them,
4138        then in level-1 zones, and so on. If it finds two mechanisms
4139        with the target name during the same search pass, it raises a
4140        `MechanismCollisionError`, but if it finds one it returns it.
4141        Raises a `MissingMechanismError` if there is no mechanisms with
4142        that name among global mechanisms (searched after the last
4143        applicable level of zones) or anywhere in the graph (which is the
4144        final level of search after checking global mechanisms).
4145
4146        For example:
4147
4148        >>> d = DecisionGraph()
4149        >>> d.addDecision('A')
4150        0
4151        >>> d.addDecision('B')
4152        1
4153        >>> d.addDecision('C')
4154        2
4155        >>> d.addDecision('D')
4156        3
4157        >>> d.addDecision('E')
4158        4
4159        >>> d.addMechanism('switch', 'A')
4160        0
4161        >>> d.addMechanism('switch', 'B')
4162        1
4163        >>> d.addMechanism('switch', 'C')
4164        2
4165        >>> d.addMechanism('lever', 'D')
4166        3
4167        >>> d.addMechanism('lever', None)  # global
4168        4
4169        >>> d.createZone('Z1', 0)
4170        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4171 annotations=[])
4172        >>> d.createZone('Z2', 0)
4173        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4174 annotations=[])
4175        >>> d.createZone('Zup', 1)
4176        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
4177 annotations=[])
4178        >>> d.addDecisionToZone('A', 'Z1')
4179        >>> d.addDecisionToZone('B', 'Z1')
4180        >>> d.addDecisionToZone('C', 'Z2')
4181        >>> d.addDecisionToZone('D', 'Z2')
4182        >>> d.addDecisionToZone('E', 'Z1')
4183        >>> d.addZoneToZone('Z1', 'Zup')
4184        >>> d.addZoneToZone('Z2', 'Zup')
4185        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
4186        Traceback (most recent call last):
4187        ...
4188        exploration.core.MechanismCollisionError...
4189        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
4190        4
4191        >>> d.lookupMechanism({'D'}, 'lever')  # local
4192        3
4193        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
4194        3
4195        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
4196        3
4197        >>> d.lookupMechanism({'A'}, 'switch')  # local
4198        0
4199        >>> d.lookupMechanism({'B'}, 'switch')  # local
4200        1
4201        >>> d.lookupMechanism({'C'}, 'switch')  # local
4202        2
4203        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
4204        Traceback (most recent call last):
4205        ...
4206        exploration.core.MechanismCollisionError...
4207        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
4208        Traceback (most recent call last):
4209        ...
4210        exploration.core.MechanismCollisionError...
4211        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
4212        1
4213        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
4214        Traceback (most recent call last):
4215        ...
4216        exploration.core.MechanismCollisionError...
4217        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
4218        Traceback (most recent call last):
4219        ...
4220        exploration.core.MechanismCollisionError...
4221        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
4222        2
4223        """
4224        result = self.localLookup(
4225            startFrom,
4226            DecisionGraph.uniqueMechanismFinder(name)
4227        )
4228        if result is None:
4229            raise MissingMechanismError(
4230                f"No mechanism named {name!r}"
4231            )
4232        else:
4233            return result

Looks up the mechanism with the given name 'closest' to the given decision or set of decisions. First it looks for a mechanism with that name that's at one of those decisions. Then it starts looking in level-0 zones which contain any of them, then in level-1 zones, and so on. If it finds two mechanisms with the target name during the same search pass, it raises a MechanismCollisionError, but if it finds one it returns it. Raises a MissingMechanismError if there is no mechanisms with that name among global mechanisms (searched after the last applicable level of zones) or anywhere in the graph (which is the final level of search after checking global mechanisms).

For example:

>>> d = DecisionGraph()
>>> d.addDecision('A')
0
>>> d.addDecision('B')
1
>>> d.addDecision('C')
2
>>> d.addDecision('D')
3
>>> d.addDecision('E')
4
>>> d.addMechanism('switch', 'A')
0
>>> d.addMechanism('switch', 'B')
1
>>> d.addMechanism('switch', 'C')
2
>>> d.addMechanism('lever', 'D')
3
>>> d.addMechanism('lever', None)  # global
4
>>> d.createZone('Z1', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Z2', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.createZone('Zup', 1)
ZoneInfo(level=1, parents=set(), contents=set(), tags={}, annotations=[])
>>> d.addDecisionToZone('A', 'Z1')
>>> d.addDecisionToZone('B', 'Z1')
>>> d.addDecisionToZone('C', 'Z2')
>>> d.addDecisionToZone('D', 'Z2')
>>> d.addDecisionToZone('E', 'Z1')
>>> d.addZoneToZone('Z1', 'Zup')
>>> d.addZoneToZone('Z2', 'Zup')
>>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
4
>>> d.lookupMechanism({'D'}, 'lever')  # local
3
>>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
3
>>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
3
>>> d.lookupMechanism({'A'}, 'switch')  # local
0
>>> d.lookupMechanism({'B'}, 'switch')  # local
1
>>> d.lookupMechanism({'C'}, 'switch')  # local
2
>>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
1
>>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
Traceback (most recent call last):
...
MechanismCollisionError...
>>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
2
def resolveMechanism( self, specifier: Union[int, str, exploration.base.MechanismSpecifier], startFrom: Union[NoneType, int, exploration.base.DecisionSpecifier, str, Collection[Union[int, exploration.base.DecisionSpecifier, str]]] = None) -> int:
4235    def resolveMechanism(
4236        self,
4237        specifier: base.AnyMechanismSpecifier,
4238        startFrom: Union[
4239            None,
4240            base.AnyDecisionSpecifier,
4241            Collection[base.AnyDecisionSpecifier]
4242        ] = None
4243    ) -> base.MechanismID:
4244        """
4245        Works like `lookupMechanism`, except it accepts a
4246        `base.AnyMechanismSpecifier` which may have position information
4247        baked in, and so the `startFrom` information is optional. If
4248        position information isn't specified in the mechanism specifier
4249        and startFrom is not provided, the mechanism is searched for at
4250        the global scope and then in the entire graph. On the other
4251        hand, if the specifier includes any position information, the
4252        startFrom value provided here will be ignored.
4253        """
4254        if isinstance(specifier, base.MechanismID):
4255            return specifier
4256
4257        elif isinstance(specifier, base.MechanismName):
4258            if startFrom is None:
4259                startFrom = set()
4260            return self.lookupMechanism(startFrom, specifier)
4261
4262        elif isinstance(specifier, tuple) and len(specifier) == 4:
4263            domain, zone, decision, mechanism = specifier
4264            if domain is None and zone is None and decision is None:
4265                if startFrom is None:
4266                    startFrom = set()
4267                return self.lookupMechanism(startFrom, mechanism)
4268
4269            elif decision is not None:
4270                startFrom = {
4271                    self.resolveDecision(
4272                        base.DecisionSpecifier(domain, zone, decision)
4273                    )
4274                }
4275                return self.lookupMechanism(startFrom, mechanism)
4276
4277            else:  # decision is None but domain and/or zone aren't
4278                startFrom = set()
4279                if zone is not None:
4280                    baseStart = self.allDecisionsInZone(zone)
4281                else:
4282                    baseStart = set(self)
4283
4284                if domain is None:
4285                    startFrom = baseStart
4286                else:
4287                    for dID in baseStart:
4288                        if self.domainFor(dID) == domain:
4289                            startFrom.add(dID)
4290                return self.lookupMechanism(startFrom, mechanism)
4291
4292        else:
4293            raise TypeError(
4294                f"Invalid mechanism specifier: {repr(specifier)}"
4295                f"\n(Must be a mechanism ID, mechanism name, or"
4296                f" mechanism specifier tuple)"
4297            )

Works like lookupMechanism, except it accepts a base.AnyMechanismSpecifier which may have position information baked in, and so the startFrom information is optional. If position information isn't specified in the mechanism specifier and startFrom is not provided, the mechanism is searched for at the global scope and then in the entire graph. On the other hand, if the specifier includes any position information, the startFrom value provided here will be ignored.

def walkConsequenceMechanisms( self, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], searchFrom: Set[int]) -> Generator[int, NoneType, NoneType]:
4299    def walkConsequenceMechanisms(
4300        self,
4301        consequence: base.Consequence,
4302        searchFrom: Set[base.DecisionID]
4303    ) -> Generator[base.MechanismID, None, None]:
4304        """
4305        Yields each requirement in the given `base.Consequence`,
4306        including those in `base.Condition`s, `base.ConditionalSkill`s
4307        within `base.Challenge`s, and those set or toggled by
4308        `base.Effect`s. The `searchFrom` argument specifies where to
4309        start searching for mechanisms, since requirements include them
4310        by name, not by ID.
4311        """
4312        for part in base.walkParts(consequence):
4313            if isinstance(part, dict):
4314                if 'skills' in part:  # a Challenge
4315                    for cSkill in part['skills'].walk():
4316                        if isinstance(cSkill, base.ConditionalSkill):
4317                            yield from self.walkRequirementMechanisms(
4318                                cSkill.requirement,
4319                                searchFrom
4320                            )
4321                elif 'condition' in part:  # a Condition
4322                    yield from self.walkRequirementMechanisms(
4323                        part['condition'],
4324                        searchFrom
4325                    )
4326                elif 'value' in part:  # an Effect
4327                    val = part['value']
4328                    if part['type'] == 'set':
4329                        if (
4330                            isinstance(val, tuple)
4331                        and len(val) == 2
4332                        and isinstance(val[1], base.State)
4333                        ):
4334                            yield from self.walkRequirementMechanisms(
4335                                base.ReqMechanism(val[0], val[1]),
4336                                searchFrom
4337                            )
4338                    elif part['type'] == 'toggle':
4339                        if isinstance(val, tuple):
4340                            assert len(val) == 2
4341                            yield from self.walkRequirementMechanisms(
4342                                base.ReqMechanism(val[0], '_'),
4343                                  # state part is ignored here
4344                                searchFrom
4345                            )

Yields each requirement in the given base.Consequence, including those in base.Conditions, base.ConditionalSkills within base.Challenges, and those set or toggled by base.Effects. The searchFrom argument specifies where to start searching for mechanisms, since requirements include them by name, not by ID.

def walkRequirementMechanisms( self, req: exploration.base.Requirement, searchFrom: Set[int]) -> Generator[int, NoneType, NoneType]:
4347    def walkRequirementMechanisms(
4348        self,
4349        req: base.Requirement,
4350        searchFrom: Set[base.DecisionID]
4351    ) -> Generator[base.MechanismID, None, None]:
4352        """
4353        Given a requirement, yields any mechanisms mentioned in that
4354        requirement, in depth-first traversal order.
4355        """
4356        for part in req.walk():
4357            if isinstance(part, base.ReqMechanism):
4358                mech = part.mechanism
4359                yield self.resolveMechanism(
4360                    mech,
4361                    startFrom=searchFrom
4362                )

Given a requirement, yields any mechanisms mentioned in that requirement, in depth-first traversal order.

def addUnexploredEdge( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], name: str, destinationName: Optional[str] = None, reciprocal: Optional[str] = 'return', toDomain: Optional[str] = None, placeInZone: Optional[str] = None, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None, revTags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, revAnnotations: Optional[List[str]] = None, requires: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, revRequires: Optional[exploration.base.Requirement] = None, revConsequece: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None) -> int:
4364    def addUnexploredEdge(
4365        self,
4366        fromDecision: base.AnyDecisionSpecifier,
4367        name: base.Transition,
4368        destinationName: Optional[base.DecisionName] = None,
4369        reciprocal: Optional[base.Transition] = 'return',
4370        toDomain: Optional[base.Domain] = None,
4371        placeInZone: Optional[base.Zone] = None,
4372        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
4373        annotations: Optional[List[base.Annotation]] = None,
4374        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
4375        revAnnotations: Optional[List[base.Annotation]] = None,
4376        requires: Optional[base.Requirement] = None,
4377        consequence: Optional[base.Consequence] = None,
4378        revRequires: Optional[base.Requirement] = None,
4379        revConsequece: Optional[base.Consequence] = None
4380    ) -> base.DecisionID:
4381        """
4382        Adds a transition connecting to a new decision named `'_u.-n-'`
4383        where '-n-' is the number of unknown decisions (named or not)
4384        that have ever been created in this graph (or using the
4385        specified destination name if one is provided). This represents
4386        a transition to an unknown destination. The destination node
4387        gets tagged 'unconfirmed'.
4388
4389        This also adds a reciprocal transition in the reverse direction,
4390        unless `reciprocal` is set to `None`. The reciprocal will use
4391        the provided name (default is 'return'). The new decision will
4392        be in the same domain as the decision it's connected to, unless
4393        `toDecision` is specified, in which case it will be in that
4394        domain.
4395
4396        The new decision will not be placed into any zones, unless
4397        `placeInZone` is specified, in which case it will be placed into
4398        that zone. If that zone needs to be created, it will be created
4399        at level 0; in that case that zone will be added to any
4400        grandparent zones of the decision we're branching off of. If
4401        `placeInZone` is set to `base.DefaultZone`, then the new
4402        decision will be placed into each parent zone of the decision
4403        we're branching off of, as long as the new decision is in the
4404        same domain as the decision we're branching from (otherwise only
4405        an explicit `placeInZone` would apply).
4406
4407        The ID of the decision that was created is returned.
4408
4409        A `MissingDecisionError` will be raised if the starting decision
4410        does not exist, a `TransitionCollisionError` will be raised if
4411        it exists but already has a transition with the given name, and a
4412        `DecisionCollisionWarning` will be issued if a decision with the
4413        specified destination name already exists (won't happen when
4414        using an automatic name).
4415
4416        Lists of tags and/or annotations (strings in both cases) may be
4417        provided. These may also be provided for the reciprocal edge.
4418
4419        Similarly, requirements and/or consequences for either edge may
4420        be provided.
4421
4422        ## Example
4423
4424        >>> g = DecisionGraph()
4425        >>> g.addDecision('A')
4426        0
4427        >>> g.addUnexploredEdge('A', 'up')
4428        1
4429        >>> g.nameFor(1)
4430        '_u.0'
4431        >>> g.decisionTags(1)
4432        {'unconfirmed': 1}
4433        >>> g.addUnexploredEdge('A', 'right', 'B')
4434        2
4435        >>> g.nameFor(2)
4436        'B'
4437        >>> g.decisionTags(2)
4438        {'unconfirmed': 1}
4439        >>> g.addUnexploredEdge('A', 'down', None, 'up')
4440        3
4441        >>> g.nameFor(3)
4442        '_u.2'
4443        >>> g.addUnexploredEdge(
4444        ...    '_u.0',
4445        ...    'beyond',
4446        ...    toDomain='otherDomain',
4447        ...    tags={'fast':1},
4448        ...    revTags={'slow':1},
4449        ...    annotations=['comment'],
4450        ...    revAnnotations=['one', 'two'],
4451        ...    requires=base.ReqCapability('dash'),
4452        ...    revRequires=base.ReqCapability('super dash'),
4453        ...    consequence=[base.effect(gain='super dash')],
4454        ...    revConsequece=[base.effect(lose='super dash')]
4455        ... )
4456        4
4457        >>> g.nameFor(4)
4458        '_u.3'
4459        >>> g.domainFor(4)
4460        'otherDomain'
4461        >>> g.transitionTags('_u.0', 'beyond')
4462        {'fast': 1}
4463        >>> g.transitionAnnotations('_u.0', 'beyond')
4464        ['comment']
4465        >>> g.getTransitionRequirement('_u.0', 'beyond')
4466        ReqCapability('dash')
4467        >>> e = g.getConsequence('_u.0', 'beyond')
4468        >>> e == [base.effect(gain='super dash')]
4469        True
4470        >>> g.transitionTags('_u.3', 'return')
4471        {'slow': 1}
4472        >>> g.transitionAnnotations('_u.3', 'return')
4473        ['one', 'two']
4474        >>> g.getTransitionRequirement('_u.3', 'return')
4475        ReqCapability('super dash')
4476        >>> e = g.getConsequence('_u.3', 'return')
4477        >>> e == [base.effect(lose='super dash')]
4478        True
4479        """
4480        # Defaults
4481        if tags is None:
4482            tags = {}
4483        if annotations is None:
4484            annotations = []
4485        if revTags is None:
4486            revTags = {}
4487        if revAnnotations is None:
4488            revAnnotations = []
4489
4490        # Resolve ID
4491        fromID = self.resolveDecision(fromDecision)
4492        if toDomain is None:
4493            toDomain = self.domainFor(fromID)
4494
4495        if name in self.destinationsFrom(fromID):
4496            raise TransitionCollisionError(
4497                f"Cannot add a new edge {name!r}:"
4498                f" {self.identityOf(fromDecision)} already has an"
4499                f" outgoing edge with that name."
4500            )
4501
4502        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
4503            warnings.warn(
4504                (
4505                    f"Cannot add a new unexplored node"
4506                    f" {destinationName!r}: A decision with that name"
4507                    f" already exists.\n(Leave destinationName as None"
4508                    f" to use an automatic name.)"
4509                ),
4510                DecisionCollisionWarning
4511            )
4512
4513        # Create the new unexplored decision and add the edge
4514        if destinationName is None:
4515            toName = '_u.' + str(self.unknownCount)
4516        else:
4517            toName = destinationName
4518        self.unknownCount += 1
4519        newID = self.addDecision(toName, domain=toDomain)
4520        self.addTransition(
4521            fromID,
4522            name,
4523            newID,
4524            tags=tags,
4525            annotations=annotations
4526        )
4527        self.setTransitionRequirement(fromID, name, requires)
4528        if consequence is not None:
4529            self.setConsequence(fromID, name, consequence)
4530
4531        # Add it to a zone if requested
4532        if (
4533            placeInZone == base.DefaultZone
4534        and toDomain == self.domainFor(fromID)
4535        ):
4536            # Add to each parent of the from decision
4537            for parent in self.zoneParents(fromID):
4538                self.addDecisionToZone(newID, parent)
4539        elif placeInZone is not None:
4540            # Otherwise add it to one specific zone, creating that zone
4541            # at level 0 if necessary
4542            assert isinstance(placeInZone, base.Zone)
4543            if self.getZoneInfo(placeInZone) is None:
4544                self.createZone(placeInZone, 0)
4545                # Add new zone to each grandparent of the from decision
4546                for parent in self.zoneParents(fromID):
4547                    for grandparent in self.zoneParents(parent):
4548                        self.addZoneToZone(placeInZone, grandparent)
4549            self.addDecisionToZone(newID, placeInZone)
4550
4551        # Create the reciprocal edge
4552        if reciprocal is not None:
4553            self.addTransition(
4554                newID,
4555                reciprocal,
4556                fromID,
4557                tags=revTags,
4558                annotations=revAnnotations
4559            )
4560            self.setTransitionRequirement(newID, reciprocal, revRequires)
4561            if revConsequece is not None:
4562                self.setConsequence(newID, reciprocal, revConsequece)
4563            # Set as a reciprocal
4564            self.setReciprocal(fromID, name, reciprocal)
4565
4566        # Tag the destination as 'unconfirmed'
4567        self.tagDecision(newID, 'unconfirmed')
4568
4569        # Return ID of new destination
4570        return newID

Adds a transition connecting to a new decision named '_u.-n-' where '-n-' is the number of unknown decisions (named or not) that have ever been created in this graph (or using the specified destination name if one is provided). This represents a transition to an unknown destination. The destination node gets tagged 'unconfirmed'.

This also adds a reciprocal transition in the reverse direction, unless reciprocal is set to None. The reciprocal will use the provided name (default is 'return'). The new decision will be in the same domain as the decision it's connected to, unless toDecision is specified, in which case it will be in that domain.

The new decision will not be placed into any zones, unless placeInZone is specified, in which case it will be placed into that zone. If that zone needs to be created, it will be created at level 0; in that case that zone will be added to any grandparent zones of the decision we're branching off of. If placeInZone is set to base.DefaultZone, then the new decision will be placed into each parent zone of the decision we're branching off of, as long as the new decision is in the same domain as the decision we're branching from (otherwise only an explicit placeInZone would apply).

The ID of the decision that was created is returned.

A MissingDecisionError will be raised if the starting decision does not exist, a TransitionCollisionError will be raised if it exists but already has a transition with the given name, and a DecisionCollisionWarning will be issued if a decision with the specified destination name already exists (won't happen when using an automatic name).

Lists of tags and/or annotations (strings in both cases) may be provided. These may also be provided for the reciprocal edge.

Similarly, requirements and/or consequences for either edge may be provided.

Example

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addUnexploredEdge('A', 'up')
1
>>> g.nameFor(1)
'_u.0'
>>> g.decisionTags(1)
{'unconfirmed': 1}
>>> g.addUnexploredEdge('A', 'right', 'B')
2
>>> g.nameFor(2)
'B'
>>> g.decisionTags(2)
{'unconfirmed': 1}
>>> g.addUnexploredEdge('A', 'down', None, 'up')
3
>>> g.nameFor(3)
'_u.2'
>>> g.addUnexploredEdge(
...    '_u.0',
...    'beyond',
...    toDomain='otherDomain',
...    tags={'fast':1},
...    revTags={'slow':1},
...    annotations=['comment'],
...    revAnnotations=['one', 'two'],
...    requires=base.ReqCapability('dash'),
...    revRequires=base.ReqCapability('super dash'),
...    consequence=[base.effect(gain='super dash')],
...    revConsequece=[base.effect(lose='super dash')]
... )
4
>>> g.nameFor(4)
'_u.3'
>>> g.domainFor(4)
'otherDomain'
>>> g.transitionTags('_u.0', 'beyond')
{'fast': 1}
>>> g.transitionAnnotations('_u.0', 'beyond')
['comment']
>>> g.getTransitionRequirement('_u.0', 'beyond')
ReqCapability('dash')
>>> e = g.getConsequence('_u.0', 'beyond')
>>> e == [base.effect(gain='super dash')]
True
>>> g.transitionTags('_u.3', 'return')
{'slow': 1}
>>> g.transitionAnnotations('_u.3', 'return')
['one', 'two']
>>> g.getTransitionRequirement('_u.3', 'return')
ReqCapability('super dash')
>>> e = g.getConsequence('_u.3', 'return')
>>> e == [base.effect(lose='super dash')]
True
def retargetTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, newDestination: Union[int, exploration.base.DecisionSpecifier, str], swapReciprocal=True, errorOnNameColision=True) -> Optional[str]:
4572    def retargetTransition(
4573        self,
4574        fromDecision: base.AnyDecisionSpecifier,
4575        transition: base.Transition,
4576        newDestination: base.AnyDecisionSpecifier,
4577        swapReciprocal=True,
4578        errorOnNameColision=True
4579    ) -> Optional[base.Transition]:
4580        """
4581        Given a particular decision and a transition at that decision,
4582        changes that transition so that it goes to the specified new
4583        destination instead of wherever it was connected to before. If
4584        the new destination is the same as the old one, no changes are
4585        made.
4586
4587        If `swapReciprocal` is set to True (the default) then any
4588        reciprocal edge at the old destination will be deleted, and a
4589        new reciprocal edge from the new destination with equivalent
4590        properties to the original reciprocal will be created, pointing
4591        to the origin of the specified transition. If `swapReciprocal`
4592        is set to False, then the reciprocal relationship with any old
4593        reciprocal edge will be removed, but the old reciprocal edge
4594        will not be changed.
4595
4596        Note that if `errorOnNameColision` is True (the default), then
4597        if the reciprocal transition has the same name as a transition
4598        which already exists at the new destination node, a
4599        `TransitionCollisionError` will be thrown. However, if it is set
4600        to False, the reciprocal transition will be renamed with a suffix
4601        to avoid any possible name collisions. Either way, the name of
4602        the reciprocal transition (possibly just changed) will be
4603        returned, or None if there was no reciprocal transition.
4604
4605        ## Example
4606
4607        >>> g = DecisionGraph()
4608        >>> for fr, to, nm in [
4609        ...     ('A', 'B', 'up'),
4610        ...     ('A', 'B', 'up2'),
4611        ...     ('B', 'A', 'down'),
4612        ...     ('B', 'B', 'self'),
4613        ...     ('B', 'C', 'next'),
4614        ...     ('C', 'B', 'prev')
4615        ... ]:
4616        ...     if g.getDecision(fr) is None:
4617        ...        g.addDecision(fr)
4618        ...     if g.getDecision(to) is None:
4619        ...         g.addDecision(to)
4620        ...     g.addTransition(fr, nm, to)
4621        0
4622        1
4623        2
4624        >>> g.setReciprocal('A', 'up', 'down')
4625        >>> g.setReciprocal('B', 'next', 'prev')
4626        >>> g.destination('A', 'up')
4627        1
4628        >>> g.destination('B', 'down')
4629        0
4630        >>> g.retargetTransition('A', 'up', 'C')
4631        'down'
4632        >>> g.destination('A', 'up')
4633        2
4634        >>> g.getDestination('B', 'down') is None
4635        True
4636        >>> g.destination('C', 'down')
4637        0
4638        >>> g.addTransition('A', 'next', 'B')
4639        >>> g.addTransition('B', 'prev', 'A')
4640        >>> g.setReciprocal('A', 'next', 'prev')
4641        >>> # Can't swap a reciprocal in a way that would collide names
4642        >>> g.getReciprocal('C', 'prev')
4643        'next'
4644        >>> g.retargetTransition('C', 'prev', 'A')
4645        Traceback (most recent call last):
4646        ...
4647        exploration.core.TransitionCollisionError...
4648        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
4649        'next'
4650        >>> g.destination('C', 'prev')
4651        0
4652        >>> g.destination('A', 'next') # not changed
4653        1
4654        >>> # Reciprocal relationship is severed:
4655        >>> g.getReciprocal('C', 'prev') is None
4656        True
4657        >>> g.getReciprocal('B', 'next') is None
4658        True
4659        >>> # Swap back so we can do another demo
4660        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
4661        >>> # Note return value was None here because there was no reciprocal
4662        >>> g.setReciprocal('C', 'prev', 'next')
4663        >>> # Swap reciprocal by renaming it
4664        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
4665        'next.1'
4666        >>> g.getReciprocal('C', 'prev')
4667        'next.1'
4668        >>> g.destination('C', 'prev')
4669        0
4670        >>> g.destination('A', 'next.1')
4671        2
4672        >>> g.destination('A', 'next')
4673        1
4674        >>> # Note names are the same but these are from different nodes
4675        >>> g.getReciprocal('A', 'next')
4676        'prev'
4677        >>> g.getReciprocal('A', 'next.1')
4678        'prev'
4679        """
4680        fromID = self.resolveDecision(fromDecision)
4681        newDestID = self.resolveDecision(newDestination)
4682
4683        # Figure out the old destination of the transition we're swapping
4684        oldDestID = self.destination(fromID, transition)
4685        reciprocal = self.getReciprocal(fromID, transition)
4686
4687        # If thew new destination is the same, we don't do anything!
4688        if oldDestID == newDestID:
4689            return reciprocal
4690
4691        # First figure out reciprocal business so we can error out
4692        # without making changes if we need to
4693        if swapReciprocal and reciprocal is not None:
4694            reciprocal = self.rebaseTransition(
4695                oldDestID,
4696                reciprocal,
4697                newDestID,
4698                swapReciprocal=False,
4699                errorOnNameColision=errorOnNameColision
4700            )
4701
4702        # Handle the forward transition...
4703        # Find the transition properties
4704        tProps = self.getTransitionProperties(fromID, transition)
4705
4706        # Delete the edge
4707        self.removeEdgeByKey(fromID, transition)
4708
4709        # Add the new edge
4710        self.addTransition(fromID, transition, newDestID)
4711
4712        # Reapply the transition properties
4713        self.setTransitionProperties(fromID, transition, **tProps)
4714
4715        # Handle the reciprocal transition if there is one...
4716        if reciprocal is not None:
4717            if not swapReciprocal:
4718                # Then sever the relationship, but only if that edge
4719                # still exists (we might be in the middle of a rebase)
4720                check = self.getDestination(oldDestID, reciprocal)
4721                if check is not None:
4722                    self.setReciprocal(
4723                        oldDestID,
4724                        reciprocal,
4725                        None,
4726                        setBoth=False # Other transition was deleted already
4727                    )
4728            else:
4729                # Establish new reciprocal relationship
4730                self.setReciprocal(
4731                    fromID,
4732                    transition,
4733                    reciprocal
4734                )
4735
4736        return reciprocal

Given a particular decision and a transition at that decision, changes that transition so that it goes to the specified new destination instead of wherever it was connected to before. If the new destination is the same as the old one, no changes are made.

If swapReciprocal is set to True (the default) then any reciprocal edge at the old destination will be deleted, and a new reciprocal edge from the new destination with equivalent properties to the original reciprocal will be created, pointing to the origin of the specified transition. If swapReciprocal is set to False, then the reciprocal relationship with any old reciprocal edge will be removed, but the old reciprocal edge will not be changed.

Note that if errorOnNameColision is True (the default), then if the reciprocal transition has the same name as a transition which already exists at the new destination node, a TransitionCollisionError will be thrown. However, if it is set to False, the reciprocal transition will be renamed with a suffix to avoid any possible name collisions. Either way, the name of the reciprocal transition (possibly just changed) will be returned, or None if there was no reciprocal transition.

Example

>>> g = DecisionGraph()
>>> for fr, to, nm in [
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ]:
...     if g.getDecision(fr) is None:
...        g.addDecision(fr)
...     if g.getDecision(to) is None:
...         g.addDecision(to)
...     g.addTransition(fr, nm, to)
0
1
2
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.destination('A', 'up')
1
>>> g.destination('B', 'down')
0
>>> g.retargetTransition('A', 'up', 'C')
'down'
>>> g.destination('A', 'up')
2
>>> g.getDestination('B', 'down') is None
True
>>> g.destination('C', 'down')
0
>>> g.addTransition('A', 'next', 'B')
>>> g.addTransition('B', 'prev', 'A')
>>> g.setReciprocal('A', 'next', 'prev')
>>> # Can't swap a reciprocal in a way that would collide names
>>> g.getReciprocal('C', 'prev')
'next'
>>> g.retargetTransition('C', 'prev', 'A')
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
'next'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next') # not changed
1
>>> # Reciprocal relationship is severed:
>>> g.getReciprocal('C', 'prev') is None
True
>>> g.getReciprocal('B', 'next') is None
True
>>> # Swap back so we can do another demo
>>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
>>> # Note return value was None here because there was no reciprocal
>>> g.setReciprocal('C', 'prev', 'next')
>>> # Swap reciprocal by renaming it
>>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
'next.1'
>>> g.getReciprocal('C', 'prev')
'next.1'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next.1')
2
>>> g.destination('A', 'next')
1
>>> # Note names are the same but these are from different nodes
>>> g.getReciprocal('A', 'next')
'prev'
>>> g.getReciprocal('A', 'next.1')
'prev'
def rebaseTransition( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, newBase: Union[int, exploration.base.DecisionSpecifier, str], swapReciprocal=True, errorOnNameColision=True) -> str:
4738    def rebaseTransition(
4739        self,
4740        fromDecision: base.AnyDecisionSpecifier,
4741        transition: base.Transition,
4742        newBase: base.AnyDecisionSpecifier,
4743        swapReciprocal=True,
4744        errorOnNameColision=True
4745    ) -> base.Transition:
4746        """
4747        Given a particular destination and a transition at that
4748        destination, changes that transition's origin to a new base
4749        decision. If the new source is the same as the old one, no
4750        changes are made.
4751
4752        If `swapReciprocal` is set to True (the default) then any
4753        reciprocal edge at the destination will be retargeted to point
4754        to the new source so that it can remain a reciprocal. If
4755        `swapReciprocal` is set to False, then the reciprocal
4756        relationship with any old reciprocal edge will be removed, but
4757        the old reciprocal edge will not be otherwise changed.
4758
4759        Note that if `errorOnNameColision` is True (the default), then
4760        if the transition has the same name as a transition which
4761        already exists at the new source node, a
4762        `TransitionCollisionError` will be raised. However, if it is set
4763        to False, the transition will be renamed with a suffix to avoid
4764        any possible name collisions. Either way, the (possibly new) name
4765        of the transition that was rebased will be returned.
4766
4767        ## Example
4768
4769        >>> g = DecisionGraph()
4770        >>> for fr, to, nm in [
4771        ...     ('A', 'B', 'up'),
4772        ...     ('A', 'B', 'up2'),
4773        ...     ('B', 'A', 'down'),
4774        ...     ('B', 'B', 'self'),
4775        ...     ('B', 'C', 'next'),
4776        ...     ('C', 'B', 'prev')
4777        ... ]:
4778        ...     if g.getDecision(fr) is None:
4779        ...        g.addDecision(fr)
4780        ...     if g.getDecision(to) is None:
4781        ...         g.addDecision(to)
4782        ...     g.addTransition(fr, nm, to)
4783        0
4784        1
4785        2
4786        >>> g.setReciprocal('A', 'up', 'down')
4787        >>> g.setReciprocal('B', 'next', 'prev')
4788        >>> g.destination('A', 'up')
4789        1
4790        >>> g.destination('B', 'down')
4791        0
4792        >>> g.rebaseTransition('B', 'down', 'C')
4793        'down'
4794        >>> g.destination('A', 'up')
4795        2
4796        >>> g.getDestination('B', 'down') is None
4797        True
4798        >>> g.destination('C', 'down')
4799        0
4800        >>> g.addTransition('A', 'next', 'B')
4801        >>> g.addTransition('B', 'prev', 'A')
4802        >>> g.setReciprocal('A', 'next', 'prev')
4803        >>> # Can't rebase in a way that would collide names
4804        >>> g.rebaseTransition('B', 'next', 'A')
4805        Traceback (most recent call last):
4806        ...
4807        exploration.core.TransitionCollisionError...
4808        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
4809        'next.1'
4810        >>> g.destination('C', 'prev')
4811        0
4812        >>> g.destination('A', 'next') # not changed
4813        1
4814        >>> # Collision is avoided by renaming
4815        >>> g.destination('A', 'next.1')
4816        2
4817        >>> # Swap without reciprocal
4818        >>> g.getReciprocal('A', 'next.1')
4819        'prev'
4820        >>> g.getReciprocal('C', 'prev')
4821        'next.1'
4822        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
4823        'next.1'
4824        >>> g.getReciprocal('C', 'prev') is None
4825        True
4826        >>> g.destination('C', 'prev')
4827        0
4828        >>> g.getDestination('A', 'next.1') is None
4829        True
4830        >>> g.destination('A', 'next')
4831        1
4832        >>> g.destination('B', 'next.1')
4833        2
4834        >>> g.getReciprocal('B', 'next.1') is None
4835        True
4836        >>> # Rebase in a way that creates a self-edge
4837        >>> g.rebaseTransition('A', 'next', 'B')
4838        'next'
4839        >>> g.getDestination('A', 'next') is None
4840        True
4841        >>> g.destination('B', 'next')
4842        1
4843        >>> g.destination('B', 'prev') # swapped as a reciprocal
4844        1
4845        >>> g.getReciprocal('B', 'next') # still reciprocals
4846        'prev'
4847        >>> g.getReciprocal('B', 'prev')
4848        'next'
4849        >>> # And rebasing of a self-edge also works
4850        >>> g.rebaseTransition('B', 'prev', 'A')
4851        'prev'
4852        >>> g.destination('A', 'prev')
4853        1
4854        >>> g.destination('B', 'next')
4855        0
4856        >>> g.getReciprocal('B', 'next') # still reciprocals
4857        'prev'
4858        >>> g.getReciprocal('A', 'prev')
4859        'next'
4860        >>> # We've effectively reversed this edge/reciprocal pair
4861        >>> # by rebasing twice
4862        """
4863        fromID = self.resolveDecision(fromDecision)
4864        newBaseID = self.resolveDecision(newBase)
4865
4866        # If thew new base is the same, we don't do anything!
4867        if newBaseID == fromID:
4868            return transition
4869
4870        # First figure out reciprocal business so we can swap it later
4871        # without making changes if we need to
4872        destination = self.destination(fromID, transition)
4873        reciprocal = self.getReciprocal(fromID, transition)
4874        # Check for an already-deleted reciprocal
4875        if (
4876            reciprocal is not None
4877        and self.getDestination(destination, reciprocal) is None
4878        ):
4879            reciprocal = None
4880
4881        # Handle the base swap...
4882        # Find the transition properties
4883        tProps = self.getTransitionProperties(fromID, transition)
4884
4885        # Check for a collision
4886        targetDestinations = self.destinationsFrom(newBaseID)
4887        if transition in targetDestinations:
4888            if errorOnNameColision:
4889                raise TransitionCollisionError(
4890                    f"Cannot rebase transition {transition!r} from"
4891                    f" {self.identityOf(fromDecision)}: it would be a"
4892                    f" duplicate transition name at the new base"
4893                    f" decision {self.identityOf(newBase)}."
4894                )
4895            else:
4896                # Figure out a good fresh name
4897                newName = utils.uniqueName(
4898                    transition,
4899                    targetDestinations
4900                )
4901        else:
4902            newName = transition
4903
4904        # Delete the edge
4905        self.removeEdgeByKey(fromID, transition)
4906
4907        # Add the new edge
4908        self.addTransition(newBaseID, newName, destination)
4909
4910        # Reapply the transition properties
4911        self.setTransitionProperties(newBaseID, newName, **tProps)
4912
4913        # Handle the reciprocal transition if there is one...
4914        if reciprocal is not None:
4915            if not swapReciprocal:
4916                # Then sever the relationship
4917                self.setReciprocal(
4918                    destination,
4919                    reciprocal,
4920                    None,
4921                    setBoth=False # Other transition was deleted already
4922                )
4923            else:
4924                # Otherwise swap the reciprocal edge
4925                self.retargetTransition(
4926                    destination,
4927                    reciprocal,
4928                    newBaseID,
4929                    swapReciprocal=False
4930                )
4931
4932                # And establish a new reciprocal relationship
4933                self.setReciprocal(
4934                    newBaseID,
4935                    newName,
4936                    reciprocal
4937                )
4938
4939        # Return the new name in case it was changed
4940        return newName

Given a particular destination and a transition at that destination, changes that transition's origin to a new base decision. If the new source is the same as the old one, no changes are made.

If swapReciprocal is set to True (the default) then any reciprocal edge at the destination will be retargeted to point to the new source so that it can remain a reciprocal. If swapReciprocal is set to False, then the reciprocal relationship with any old reciprocal edge will be removed, but the old reciprocal edge will not be otherwise changed.

Note that if errorOnNameColision is True (the default), then if the transition has the same name as a transition which already exists at the new source node, a TransitionCollisionError will be raised. However, if it is set to False, the transition will be renamed with a suffix to avoid any possible name collisions. Either way, the (possibly new) name of the transition that was rebased will be returned.

Example

>>> g = DecisionGraph()
>>> for fr, to, nm in [
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev')
... ]:
...     if g.getDecision(fr) is None:
...        g.addDecision(fr)
...     if g.getDecision(to) is None:
...         g.addDecision(to)
...     g.addTransition(fr, nm, to)
0
1
2
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.destination('A', 'up')
1
>>> g.destination('B', 'down')
0
>>> g.rebaseTransition('B', 'down', 'C')
'down'
>>> g.destination('A', 'up')
2
>>> g.getDestination('B', 'down') is None
True
>>> g.destination('C', 'down')
0
>>> g.addTransition('A', 'next', 'B')
>>> g.addTransition('B', 'prev', 'A')
>>> g.setReciprocal('A', 'next', 'prev')
>>> # Can't rebase in a way that would collide names
>>> g.rebaseTransition('B', 'next', 'A')
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
'next.1'
>>> g.destination('C', 'prev')
0
>>> g.destination('A', 'next') # not changed
1
>>> # Collision is avoided by renaming
>>> g.destination('A', 'next.1')
2
>>> # Swap without reciprocal
>>> g.getReciprocal('A', 'next.1')
'prev'
>>> g.getReciprocal('C', 'prev')
'next.1'
>>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
'next.1'
>>> g.getReciprocal('C', 'prev') is None
True
>>> g.destination('C', 'prev')
0
>>> g.getDestination('A', 'next.1') is None
True
>>> g.destination('A', 'next')
1
>>> g.destination('B', 'next.1')
2
>>> g.getReciprocal('B', 'next.1') is None
True
>>> # Rebase in a way that creates a self-edge
>>> g.rebaseTransition('A', 'next', 'B')
'next'
>>> g.getDestination('A', 'next') is None
True
>>> g.destination('B', 'next')
1
>>> g.destination('B', 'prev') # swapped as a reciprocal
1
>>> g.getReciprocal('B', 'next') # still reciprocals
'prev'
>>> g.getReciprocal('B', 'prev')
'next'
>>> # And rebasing of a self-edge also works
>>> g.rebaseTransition('B', 'prev', 'A')
'prev'
>>> g.destination('A', 'prev')
1
>>> g.destination('B', 'next')
0
>>> g.getReciprocal('B', 'next') # still reciprocals
'prev'
>>> g.getReciprocal('A', 'prev')
'next'
>>> # We've effectively reversed this edge/reciprocal pair
>>> # by rebasing twice
def mergeDecisions( self, merge: Union[int, exploration.base.DecisionSpecifier, str], mergeInto: Union[int, exploration.base.DecisionSpecifier, str], errorOnNameColision=True) -> Dict[str, str]:
4946    def mergeDecisions(
4947        self,
4948        merge: base.AnyDecisionSpecifier,
4949        mergeInto: base.AnyDecisionSpecifier,
4950        errorOnNameColision=True
4951    ) -> Dict[base.Transition, base.Transition]:
4952        """
4953        Merges two decisions, deleting the first after transferring all
4954        of its incoming and outgoing edges to target the second one,
4955        whose name is retained. The second decision will be added to any
4956        zones that the first decision was a member of. If either decision
4957        does not exist, a `MissingDecisionError` will be raised. If
4958        `merge` and `mergeInto` are the same, then nothing will be
4959        changed.
4960
4961        Unless `errorOnNameColision` is set to False, a
4962        `TransitionCollisionError` will be raised if the two decisions
4963        have outgoing transitions with the same name. If
4964        `errorOnNameColision` is set to False, then such edges will be
4965        renamed using a suffix to avoid name collisions, with edges
4966        connected to the second decision retaining their original names
4967        and edges that were connected to the first decision getting
4968        renamed.
4969
4970        Any mechanisms located at the first decision will be moved to the
4971        merged decision.
4972
4973        The tags and annotations of the merged decision are added to the
4974        tags and annotations of the merge target. If there are shared
4975        tags, the values from the merge target will override those of
4976        the merged decision. If this is undesired behavior, clear/edit
4977        the tags/annotations of the merged decision before the merge.
4978
4979        The 'unconfirmed' tag is treated specially: if both decisions have
4980        it it will be retained, but otherwise it will be dropped even if
4981        one of the situations had it before.
4982
4983        The domain of the second decision is retained.
4984
4985        Returns a dictionary mapping each original transition name to
4986        its new name in cases where transitions get renamed; this will
4987        be empty when no re-naming occurs, including when
4988        `errorOnNameColision` is True. If there were any transitions
4989        connecting the nodes that were merged, these become self-edges
4990        of the merged node (and may be renamed if necessary).
4991        Note that all renamed transitions were originally based on the
4992        first (merged) node, since transitions of the second (merge
4993        target) node are not renamed.
4994
4995        ## Example
4996
4997        >>> g = DecisionGraph()
4998        >>> for fr, to, nm in [
4999        ...     ('A', 'B', 'up'),
5000        ...     ('A', 'B', 'up2'),
5001        ...     ('B', 'A', 'down'),
5002        ...     ('B', 'B', 'self'),
5003        ...     ('B', 'C', 'next'),
5004        ...     ('C', 'B', 'prev'),
5005        ...     ('A', 'C', 'right')
5006        ... ]:
5007        ...     if g.getDecision(fr) is None:
5008        ...        g.addDecision(fr)
5009        ...     if g.getDecision(to) is None:
5010        ...         g.addDecision(to)
5011        ...     g.addTransition(fr, nm, to)
5012        0
5013        1
5014        2
5015        >>> g.getDestination('A', 'up')
5016        1
5017        >>> g.getDestination('B', 'down')
5018        0
5019        >>> sorted(g)
5020        [0, 1, 2]
5021        >>> g.setReciprocal('A', 'up', 'down')
5022        >>> g.setReciprocal('B', 'next', 'prev')
5023        >>> g.mergeDecisions('C', 'B')
5024        {}
5025        >>> g.destinationsFrom('A')
5026        {'up': 1, 'up2': 1, 'right': 1}
5027        >>> g.destinationsFrom('B')
5028        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
5029        >>> 'C' in g
5030        False
5031        >>> g.mergeDecisions('A', 'A') # does nothing
5032        {}
5033        >>> # Can't merge non-existent decision
5034        >>> g.mergeDecisions('A', 'Z')
5035        Traceback (most recent call last):
5036        ...
5037        exploration.core.MissingDecisionError...
5038        >>> g.mergeDecisions('Z', 'A')
5039        Traceback (most recent call last):
5040        ...
5041        exploration.core.MissingDecisionError...
5042        >>> # Can't merge decisions w/ shared edge names
5043        >>> g.addDecision('D')
5044        3
5045        >>> g.addTransition('D', 'next', 'A')
5046        >>> g.addTransition('A', 'prev', 'D')
5047        >>> g.setReciprocal('D', 'next', 'prev')
5048        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
5049        Traceback (most recent call last):
5050        ...
5051        exploration.core.TransitionCollisionError...
5052        >>> # Auto-rename colliding edges
5053        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
5054        {'next': 'next.1'}
5055        >>> g.destination('B', 'next') # merge target unchanged
5056        1
5057        >>> g.destination('B', 'next.1') # merged decision name changed
5058        0
5059        >>> g.destination('B', 'prev') # name unchanged (no collision)
5060        1
5061        >>> g.getReciprocal('B', 'next') # unchanged (from B)
5062        'prev'
5063        >>> g.getReciprocal('B', 'next.1') # from A
5064        'prev'
5065        >>> g.getReciprocal('A', 'prev') # from B
5066        'next.1'
5067
5068        ## Folding four nodes into a 2-node loop
5069
5070        >>> g = DecisionGraph()
5071        >>> g.addDecision('X')
5072        0
5073        >>> g.addDecision('Y')
5074        1
5075        >>> g.addTransition('X', 'next', 'Y', 'prev')
5076        >>> g.addDecision('preX')
5077        2
5078        >>> g.addDecision('postY')
5079        3
5080        >>> g.addTransition('preX', 'next', 'X', 'prev')
5081        >>> g.addTransition('Y', 'next', 'postY', 'prev')
5082        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
5083        {'next': 'next.1'}
5084        >>> g.destinationsFrom('X')
5085        {'next': 1, 'prev': 1}
5086        >>> g.destinationsFrom('Y')
5087        {'prev': 0, 'next': 3, 'next.1': 0}
5088        >>> 2 in g
5089        False
5090        >>> g.destinationsFrom('postY')
5091        {'prev': 1}
5092        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
5093        {'prev': 'prev.1'}
5094        >>> g.destinationsFrom('X')
5095        {'next': 1, 'prev': 1, 'prev.1': 1}
5096        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
5097        {'prev': 0, 'next.1': 0, 'next': 0}
5098        >>> 2 in g
5099        False
5100        >>> 3 in g
5101        False
5102        >>> # Reciprocals are tangled...
5103        >>> g.getReciprocal(0, 'prev')
5104        'next.1'
5105        >>> g.getReciprocal(0, 'prev.1')
5106        'next'
5107        >>> g.getReciprocal(1, 'next')
5108        'prev.1'
5109        >>> g.getReciprocal(1, 'next.1')
5110        'prev'
5111        >>> # Note: one merge cannot handle both extra transitions
5112        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
5113        >>> # (It would merge both edges but the result would retain
5114        >>> # 'next.1' instead of retaining 'next'.)
5115        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
5116        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
5117        >>> g.destinationsFrom('X')
5118        {'next': 1, 'prev': 1}
5119        >>> g.destinationsFrom('Y')
5120        {'prev': 0, 'next': 0}
5121        >>> # Reciprocals were salvaged in second merger
5122        >>> g.getReciprocal('X', 'prev')
5123        'next'
5124        >>> g.getReciprocal('Y', 'next')
5125        'prev'
5126
5127        ## Merging with tags/requirements/annotations/consequences
5128
5129        >>> g = DecisionGraph()
5130        >>> g.addDecision('X')
5131        0
5132        >>> g.addDecision('Y')
5133        1
5134        >>> g.addDecision('Z')
5135        2
5136        >>> g.addTransition('X', 'next', 'Y', 'prev')
5137        >>> g.addTransition('X', 'down', 'Z', 'up')
5138        >>> g.tagDecision('X', 'tag0', 1)
5139        >>> g.tagDecision('Y', 'tag1', 10)
5140        >>> g.tagDecision('Y', 'unconfirmed')
5141        >>> g.tagDecision('Z', 'tag1', 20)
5142        >>> g.tagDecision('Z', 'tag2', 30)
5143        >>> g.tagTransition('X', 'next', 'ttag1', 11)
5144        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
5145        >>> g.tagTransition('X', 'down', 'ttag3', 33)
5146        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
5147        >>> g.annotateDecision('Y', 'annotation 1')
5148        >>> g.annotateDecision('Z', 'annotation 2')
5149        >>> g.annotateDecision('Z', 'annotation 3')
5150        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
5151        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
5152        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
5153        >>> g.setTransitionRequirement(
5154        ...     'X',
5155        ...     'next',
5156        ...     base.ReqCapability('power')
5157        ... )
5158        >>> g.setTransitionRequirement(
5159        ...     'Y',
5160        ...     'prev',
5161        ...     base.ReqTokens('token', 1)
5162        ... )
5163        >>> g.setTransitionRequirement(
5164        ...     'X',
5165        ...     'down',
5166        ...     base.ReqCapability('power2')
5167        ... )
5168        >>> g.setTransitionRequirement(
5169        ...     'Z',
5170        ...     'up',
5171        ...     base.ReqTokens('token2', 2)
5172        ... )
5173        >>> g.setConsequence(
5174        ...     'Y',
5175        ...     'prev',
5176        ...     [base.effect(gain="power2")]
5177        ... )
5178        >>> g.mergeDecisions('Y', 'Z')
5179        {}
5180        >>> g.destination('X', 'next')
5181        2
5182        >>> g.destination('X', 'down')
5183        2
5184        >>> g.destination('Z', 'prev')
5185        0
5186        >>> g.destination('Z', 'up')
5187        0
5188        >>> g.decisionTags('X')
5189        {'tag0': 1}
5190        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
5191        {'tag1': 20, 'tag2': 30}
5192        >>> g.transitionTags('X', 'next')
5193        {'ttag1': 11}
5194        >>> g.transitionTags('X', 'down')
5195        {'ttag3': 33}
5196        >>> g.transitionTags('Z', 'prev')
5197        {'ttag2': 22}
5198        >>> g.transitionTags('Z', 'up')
5199        {'ttag4': 44}
5200        >>> g.decisionAnnotations('Z')
5201        ['annotation 2', 'annotation 3', 'annotation 1']
5202        >>> g.transitionAnnotations('Z', 'prev')
5203        ['trans annotation 1', 'trans annotation 2']
5204        >>> g.transitionAnnotations('Z', 'up')
5205        ['trans annotation 3']
5206        >>> g.getTransitionRequirement('X', 'next')
5207        ReqCapability('power')
5208        >>> g.getTransitionRequirement('Z', 'prev')
5209        ReqTokens('token', 1)
5210        >>> g.getTransitionRequirement('X', 'down')
5211        ReqCapability('power2')
5212        >>> g.getTransitionRequirement('Z', 'up')
5213        ReqTokens('token2', 2)
5214        >>> g.getConsequence('Z', 'prev') == [
5215        ...     {
5216        ...         'type': 'gain',
5217        ...         'applyTo': 'active',
5218        ...         'value': 'power2',
5219        ...         'charges': None,
5220        ...         'delay': None,
5221        ...         'hidden': False
5222        ...     }
5223        ... ]
5224        True
5225
5226        ## Merging into node without tags
5227
5228        >>> g = DecisionGraph()
5229        >>> g.addDecision('X')
5230        0
5231        >>> g.addDecision('Y')
5232        1
5233        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
5234        >>> g.tagDecision('Y', 'tag', 'value')
5235        >>> g.mergeDecisions('Y', 'X')
5236        {}
5237        >>> g.decisionTags('X')
5238        {'tag': 'value'}
5239        >>> 0 in g  # Second argument remains
5240        True
5241        >>> 1 in g  # First argument is deleted
5242        False
5243        """
5244        # Resolve IDs
5245        mergeID = self.resolveDecision(merge)
5246        mergeIntoID = self.resolveDecision(mergeInto)
5247
5248        # Create our result as an empty dictionary
5249        result: Dict[base.Transition, base.Transition] = {}
5250
5251        # Short-circuit if the two decisions are the same
5252        if mergeID == mergeIntoID:
5253            return result
5254
5255        # MissingDecisionErrors from here if either doesn't exist
5256        allNewOutgoing = set(self.destinationsFrom(mergeID))
5257        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
5258        # Find colliding transition names
5259        collisions = allNewOutgoing & allOldOutgoing
5260        if len(collisions) > 0 and errorOnNameColision:
5261            raise TransitionCollisionError(
5262                f"Cannot merge decision {self.identityOf(merge)} into"
5263                f" decision {self.identityOf(mergeInto)}: the decisions"
5264                f" share {len(collisions)} transition names:"
5265                f" {collisions}\n(Note that errorOnNameColision was set"
5266                f" to True, set it to False to allow the operation by"
5267                f" renaming half of those transitions.)"
5268            )
5269
5270        # Record zones that will have to change after the merge
5271        zoneParents = self.zoneParents(mergeID)
5272
5273        # First, swap all incoming edges, along with their reciprocals
5274        # This will include self-edges, which will be retargeted and
5275        # whose reciprocals will be rebased in the process, leading to
5276        # the possibility of a missing edge during the loop
5277        for source, incoming in self.allEdgesTo(mergeID):
5278            # Skip this edge if it was already swapped away because it's
5279            # a self-loop with a reciprocal whose reciprocal was
5280            # processed earlier in the loop
5281            if incoming not in self.destinationsFrom(source):
5282                continue
5283
5284            # Find corresponding outgoing edge
5285            outgoing = self.getReciprocal(source, incoming)
5286
5287            # Swap both edges to new destination
5288            newOutgoing = self.retargetTransition(
5289                source,
5290                incoming,
5291                mergeIntoID,
5292                swapReciprocal=True,
5293                errorOnNameColision=False # collisions were detected above
5294            )
5295            # Add to our result if the name of the reciprocal was
5296            # changed
5297            if (
5298                outgoing is not None
5299            and newOutgoing is not None
5300            and outgoing != newOutgoing
5301            ):
5302                result[outgoing] = newOutgoing
5303
5304        # Next, swap any remaining outgoing edges (which didn't have
5305        # reciprocals, or they'd already be swapped, unless they were
5306        # self-edges previously). Note that in this loop, there can't be
5307        # any self-edges remaining, although there might be connections
5308        # between the merging nodes that need to become self-edges
5309        # because they used to be a self-edge that was half-retargeted
5310        # by the previous loop.
5311        # Note: a copy is used here to avoid iterating over a changing
5312        # dictionary
5313        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
5314            newOutgoing = self.rebaseTransition(
5315                mergeID,
5316                stillOutgoing,
5317                mergeIntoID,
5318                swapReciprocal=True,
5319                errorOnNameColision=False # collisions were detected above
5320            )
5321            if stillOutgoing != newOutgoing:
5322                result[stillOutgoing] = newOutgoing
5323
5324        # At this point, there shouldn't be any remaining incoming or
5325        # outgoing edges!
5326        assert self.degree(mergeID) == 0
5327
5328        # Merge tags & annotations
5329        # Note that these operations affect the underlying graph
5330        destTags = self.decisionTags(mergeIntoID)
5331        destUnvisited = 'unconfirmed' in destTags
5332        sourceTags = self.decisionTags(mergeID)
5333        sourceUnvisited = 'unconfirmed' in sourceTags
5334        # Copy over only new tags, leaving existing tags alone
5335        for key in sourceTags:
5336            if key not in destTags:
5337                destTags[key] = sourceTags[key]
5338
5339        if int(destUnvisited) + int(sourceUnvisited) == 1:
5340            del destTags['unconfirmed']
5341
5342        self.decisionAnnotations(mergeIntoID).extend(
5343            self.decisionAnnotations(mergeID)
5344        )
5345
5346        # Transfer zones
5347        for zone in zoneParents:
5348            self.addDecisionToZone(mergeIntoID, zone)
5349
5350        # Delete the old node
5351        self.removeDecision(mergeID)
5352
5353        return result

Merges two decisions, deleting the first after transferring all of its incoming and outgoing edges to target the second one, whose name is retained. The second decision will be added to any zones that the first decision was a member of. If either decision does not exist, a MissingDecisionError will be raised. If merge and mergeInto are the same, then nothing will be changed.

Unless errorOnNameColision is set to False, a TransitionCollisionError will be raised if the two decisions have outgoing transitions with the same name. If errorOnNameColision is set to False, then such edges will be renamed using a suffix to avoid name collisions, with edges connected to the second decision retaining their original names and edges that were connected to the first decision getting renamed.

Any mechanisms located at the first decision will be moved to the merged decision.

The tags and annotations of the merged decision are added to the tags and annotations of the merge target. If there are shared tags, the values from the merge target will override those of the merged decision. If this is undesired behavior, clear/edit the tags/annotations of the merged decision before the merge.

The 'unconfirmed' tag is treated specially: if both decisions have it it will be retained, but otherwise it will be dropped even if one of the situations had it before.

The domain of the second decision is retained.

Returns a dictionary mapping each original transition name to its new name in cases where transitions get renamed; this will be empty when no re-naming occurs, including when errorOnNameColision is True. If there were any transitions connecting the nodes that were merged, these become self-edges of the merged node (and may be renamed if necessary). Note that all renamed transitions were originally based on the first (merged) node, since transitions of the second (merge target) node are not renamed.

Example

>>> g = DecisionGraph()
>>> for fr, to, nm in [
...     ('A', 'B', 'up'),
...     ('A', 'B', 'up2'),
...     ('B', 'A', 'down'),
...     ('B', 'B', 'self'),
...     ('B', 'C', 'next'),
...     ('C', 'B', 'prev'),
...     ('A', 'C', 'right')
... ]:
...     if g.getDecision(fr) is None:
...        g.addDecision(fr)
...     if g.getDecision(to) is None:
...         g.addDecision(to)
...     g.addTransition(fr, nm, to)
0
1
2
>>> g.getDestination('A', 'up')
1
>>> g.getDestination('B', 'down')
0
>>> sorted(g)
[0, 1, 2]
>>> g.setReciprocal('A', 'up', 'down')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.mergeDecisions('C', 'B')
{}
>>> g.destinationsFrom('A')
{'up': 1, 'up2': 1, 'right': 1}
>>> g.destinationsFrom('B')
{'down': 0, 'self': 1, 'prev': 1, 'next': 1}
>>> 'C' in g
False
>>> g.mergeDecisions('A', 'A') # does nothing
{}
>>> # Can't merge non-existent decision
>>> g.mergeDecisions('A', 'Z')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.mergeDecisions('Z', 'A')
Traceback (most recent call last):
...
MissingDecisionError...
>>> # Can't merge decisions w/ shared edge names
>>> g.addDecision('D')
3
>>> g.addTransition('D', 'next', 'A')
>>> g.addTransition('A', 'prev', 'D')
>>> g.setReciprocal('D', 'next', 'prev')
>>> g.mergeDecisions('D', 'B') # both have a 'next' transition
Traceback (most recent call last):
...
TransitionCollisionError...
>>> # Auto-rename colliding edges
>>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
{'next': 'next.1'}
>>> g.destination('B', 'next') # merge target unchanged
1
>>> g.destination('B', 'next.1') # merged decision name changed
0
>>> g.destination('B', 'prev') # name unchanged (no collision)
1
>>> g.getReciprocal('B', 'next') # unchanged (from B)
'prev'
>>> g.getReciprocal('B', 'next.1') # from A
'prev'
>>> g.getReciprocal('A', 'prev') # from B
'next.1'

Folding four nodes into a 2-node loop

>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.addTransition('X', 'next', 'Y', 'prev')
>>> g.addDecision('preX')
2
>>> g.addDecision('postY')
3
>>> g.addTransition('preX', 'next', 'X', 'prev')
>>> g.addTransition('Y', 'next', 'postY', 'prev')
>>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
{'next': 'next.1'}
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1}
>>> g.destinationsFrom('Y')
{'prev': 0, 'next': 3, 'next.1': 0}
>>> 2 in g
False
>>> g.destinationsFrom('postY')
{'prev': 1}
>>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
{'prev': 'prev.1'}
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1, 'prev.1': 1}
>>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
{'prev': 0, 'next.1': 0, 'next': 0}
>>> 2 in g
False
>>> 3 in g
False
>>> # Reciprocals are tangled...
>>> g.getReciprocal(0, 'prev')
'next.1'
>>> g.getReciprocal(0, 'prev.1')
'next'
>>> g.getReciprocal(1, 'next')
'prev.1'
>>> g.getReciprocal(1, 'next.1')
'prev'
>>> # Note: one merge cannot handle both extra transitions
>>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
>>> # (It would merge both edges but the result would retain
>>> # 'next.1' instead of retaining 'next'.)
>>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
>>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
>>> g.destinationsFrom('X')
{'next': 1, 'prev': 1}
>>> g.destinationsFrom('Y')
{'prev': 0, 'next': 0}
>>> # Reciprocals were salvaged in second merger
>>> g.getReciprocal('X', 'prev')
'next'
>>> g.getReciprocal('Y', 'next')
'prev'

Merging with tags/requirements/annotations/consequences

>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.addDecision('Z')
2
>>> g.addTransition('X', 'next', 'Y', 'prev')
>>> g.addTransition('X', 'down', 'Z', 'up')
>>> g.tagDecision('X', 'tag0', 1)
>>> g.tagDecision('Y', 'tag1', 10)
>>> g.tagDecision('Y', 'unconfirmed')
>>> g.tagDecision('Z', 'tag1', 20)
>>> g.tagDecision('Z', 'tag2', 30)
>>> g.tagTransition('X', 'next', 'ttag1', 11)
>>> g.tagTransition('Y', 'prev', 'ttag2', 22)
>>> g.tagTransition('X', 'down', 'ttag3', 33)
>>> g.tagTransition('Z', 'up', 'ttag4', 44)
>>> g.annotateDecision('Y', 'annotation 1')
>>> g.annotateDecision('Z', 'annotation 2')
>>> g.annotateDecision('Z', 'annotation 3')
>>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
>>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
>>> g.annotateTransition('Z', 'up', 'trans annotation 3')
>>> g.setTransitionRequirement(
...     'X',
...     'next',
...     base.ReqCapability('power')
... )
>>> g.setTransitionRequirement(
...     'Y',
...     'prev',
...     base.ReqTokens('token', 1)
... )
>>> g.setTransitionRequirement(
...     'X',
...     'down',
...     base.ReqCapability('power2')
... )
>>> g.setTransitionRequirement(
...     'Z',
...     'up',
...     base.ReqTokens('token2', 2)
... )
>>> g.setConsequence(
...     'Y',
...     'prev',
...     [base.effect(gain="power2")]
... )
>>> g.mergeDecisions('Y', 'Z')
{}
>>> g.destination('X', 'next')
2
>>> g.destination('X', 'down')
2
>>> g.destination('Z', 'prev')
0
>>> g.destination('Z', 'up')
0
>>> g.decisionTags('X')
{'tag0': 1}
>>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
{'tag1': 20, 'tag2': 30}
>>> g.transitionTags('X', 'next')
{'ttag1': 11}
>>> g.transitionTags('X', 'down')
{'ttag3': 33}
>>> g.transitionTags('Z', 'prev')
{'ttag2': 22}
>>> g.transitionTags('Z', 'up')
{'ttag4': 44}
>>> g.decisionAnnotations('Z')
['annotation 2', 'annotation 3', 'annotation 1']
>>> g.transitionAnnotations('Z', 'prev')
['trans annotation 1', 'trans annotation 2']
>>> g.transitionAnnotations('Z', 'up')
['trans annotation 3']
>>> g.getTransitionRequirement('X', 'next')
ReqCapability('power')
>>> g.getTransitionRequirement('Z', 'prev')
ReqTokens('token', 1)
>>> g.getTransitionRequirement('X', 'down')
ReqCapability('power2')
>>> g.getTransitionRequirement('Z', 'up')
ReqTokens('token2', 2)
>>> g.getConsequence('Z', 'prev') == [
...     {
...         'type': 'gain',
...         'applyTo': 'active',
...         'value': 'power2',
...         'charges': None,
...         'delay': None,
...         'hidden': False
...     }
... ]
True

Merging into node without tags

>>> g = DecisionGraph()
>>> g.addDecision('X')
0
>>> g.addDecision('Y')
1
>>> g.tagDecision('Y', 'unconfirmed')  # special handling
>>> g.tagDecision('Y', 'tag', 'value')
>>> g.mergeDecisions('Y', 'X')
{}
>>> g.decisionTags('X')
{'tag': 'value'}
>>> 0 in g  # Second argument remains
True
>>> 1 in g  # First argument is deleted
False
def removeDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> None:
5355    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
5356        """
5357        Deletes the specified decision from the graph, updating
5358        attendant structures like zones. Note that the ID of the deleted
5359        node will NOT be reused, unless it's specifically provided to
5360        `addIdentifiedDecision`.
5361
5362        For example:
5363
5364        >>> dg = DecisionGraph()
5365        >>> dg.addDecision('A')
5366        0
5367        >>> dg.addDecision('B')
5368        1
5369        >>> list(dg)
5370        [0, 1]
5371        >>> 1 in dg
5372        True
5373        >>> 'B' in dg.nameLookup
5374        True
5375        >>> dg.removeDecision('B')
5376        >>> 1 in dg
5377        False
5378        >>> list(dg)
5379        [0]
5380        >>> 'B' in dg.nameLookup
5381        False
5382        >>> dg.addDecision('C')  # doesn't re-use ID
5383        2
5384        """
5385        dID = self.resolveDecision(decision)
5386
5387        # Remove the target from all zones:
5388        for zone in self.zones:
5389            self.removeDecisionFromZone(dID, zone)
5390
5391        # Remove the node but record the current name
5392        name = self.nodes[dID]['name']
5393        self.remove_node(dID)
5394
5395        # Clean up the nameLookup entry
5396        luInfo = self.nameLookup[name]
5397        luInfo.remove(dID)
5398        if len(luInfo) == 0:
5399            self.nameLookup.pop(name)
5400
5401        # TODO: Clean up edges?

Deletes the specified decision from the graph, updating attendant structures like zones. Note that the ID of the deleted node will NOT be reused, unless it's specifically provided to addIdentifiedDecision.

For example:

>>> dg = DecisionGraph()
>>> dg.addDecision('A')
0
>>> dg.addDecision('B')
1
>>> list(dg)
[0, 1]
>>> 1 in dg
True
>>> 'B' in dg.nameLookup
True
>>> dg.removeDecision('B')
>>> 1 in dg
False
>>> list(dg)
[0]
>>> 'B' in dg.nameLookup
False
>>> dg.addDecision('C')  # doesn't re-use ID
2
def renameDecision( self, decision: Union[int, exploration.base.DecisionSpecifier, str], newName: str):
5403    def renameDecision(
5404        self,
5405        decision: base.AnyDecisionSpecifier,
5406        newName: base.DecisionName
5407    ):
5408        """
5409        Renames a decision. The decision retains its old ID.
5410
5411        Generates a `DecisionCollisionWarning` if a decision using the new
5412        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
5413
5414        Example:
5415
5416        >>> g = DecisionGraph()
5417        >>> g.addDecision('one')
5418        0
5419        >>> g.addDecision('three')
5420        1
5421        >>> g.addTransition('one', '>', 'three')
5422        >>> g.addTransition('three', '<', 'one')
5423        >>> g.tagDecision('three', 'hi')
5424        >>> g.annotateDecision('three', 'note')
5425        >>> g.destination('one', '>')
5426        1
5427        >>> g.destination('three', '<')
5428        0
5429        >>> g.renameDecision('three', 'two')
5430        >>> g.resolveDecision('one')
5431        0
5432        >>> g.resolveDecision('two')
5433        1
5434        >>> g.resolveDecision('three')
5435        Traceback (most recent call last):
5436        ...
5437        exploration.core.MissingDecisionError...
5438        >>> g.destination('one', '>')
5439        1
5440        >>> g.nameFor(1)
5441        'two'
5442        >>> g.getDecision('three') is None
5443        True
5444        >>> g.destination('two', '<')
5445        0
5446        >>> g.decisionTags('two')
5447        {'hi': 1}
5448        >>> g.decisionAnnotations('two')
5449        ['note']
5450        """
5451        dID = self.resolveDecision(decision)
5452
5453        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
5454            warnings.warn(
5455                (
5456                    f"Can't rename {self.identityOf(decision)} as"
5457                    f" {newName!r} because a decision with that name"
5458                    f" already exists."
5459                ),
5460                DecisionCollisionWarning
5461            )
5462
5463        # Update name in node
5464        oldName = self.nodes[dID]['name']
5465        self.nodes[dID]['name'] = newName
5466
5467        # Update nameLookup entries
5468        oldNL = self.nameLookup[oldName]
5469        oldNL.remove(dID)
5470        if len(oldNL) == 0:
5471            self.nameLookup.pop(oldName)
5472        self.nameLookup.setdefault(newName, []).append(dID)

Renames a decision. The decision retains its old ID.

Generates a DecisionCollisionWarning if a decision using the new name already exists and WARN_OF_NAME_COLLISIONS is enabled.

Example:

>>> g = DecisionGraph()
>>> g.addDecision('one')
0
>>> g.addDecision('three')
1
>>> g.addTransition('one', '>', 'three')
>>> g.addTransition('three', '<', 'one')
>>> g.tagDecision('three', 'hi')
>>> g.annotateDecision('three', 'note')
>>> g.destination('one', '>')
1
>>> g.destination('three', '<')
0
>>> g.renameDecision('three', 'two')
>>> g.resolveDecision('one')
0
>>> g.resolveDecision('two')
1
>>> g.resolveDecision('three')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.destination('one', '>')
1
>>> g.nameFor(1)
'two'
>>> g.getDecision('three') is None
True
>>> g.destination('two', '<')
0
>>> g.decisionTags('two')
{'hi': 1}
>>> g.decisionAnnotations('two')
['note']
def mergeTransitions( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], merge: str, mergeInto: str, mergeReciprocal=True) -> None:
5474    def mergeTransitions(
5475        self,
5476        fromDecision: base.AnyDecisionSpecifier,
5477        merge: base.Transition,
5478        mergeInto: base.Transition,
5479        mergeReciprocal=True
5480    ) -> None:
5481        """
5482        Given a decision and two transitions that start at that decision,
5483        merges the first transition into the second transition, combining
5484        their transition properties (using `mergeProperties`) and
5485        deleting the first transition. By default any reciprocal of the
5486        first transition is also merged into the reciprocal of the
5487        second, although you can set `mergeReciprocal` to `False` to
5488        disable this in which case the old reciprocal will lose its
5489        reciprocal relationship, even if the transition that was merged
5490        into does not have a reciprocal.
5491
5492        If the two names provided are the same, nothing will happen.
5493
5494        If the two transitions do not share the same destination, they
5495        cannot be merged, and an `InvalidDestinationError` will result.
5496        Use `retargetTransition` beforehand to ensure that they do if you
5497        want to merge transitions with different destinations.
5498
5499        A `MissingDecisionError` or `MissingTransitionError` will result
5500        if the decision or either transition does not exist.
5501
5502        If merging reciprocal properties was requested and the first
5503        transition does not have a reciprocal, then no reciprocal
5504        properties change. However, if the second transition does not
5505        have a reciprocal and the first does, the first transition's
5506        reciprocal will be set to the reciprocal of the second
5507        transition, and that transition will not be deleted as usual.
5508
5509        ## Example
5510
5511        >>> g = DecisionGraph()
5512        >>> g.addDecision('A')
5513        0
5514        >>> g.addDecision('B')
5515        1
5516        >>> g.addTransition('A', 'up', 'B')
5517        >>> g.addTransition('B', 'down', 'A')
5518        >>> g.setReciprocal('A', 'up', 'down')
5519        >>> # Merging a transition with no reciprocal
5520        >>> g.addTransition('A', 'up2', 'B')
5521        >>> g.mergeTransitions('A', 'up2', 'up')
5522        >>> g.getDestination('A', 'up2') is None
5523        True
5524        >>> g.getDestination('A', 'up')
5525        1
5526        >>> # Merging a transition with a reciprocal & tags
5527        >>> g.addTransition('A', 'up2', 'B')
5528        >>> g.addTransition('B', 'down2', 'A')
5529        >>> g.setReciprocal('A', 'up2', 'down2')
5530        >>> g.tagTransition('A', 'up2', 'one')
5531        >>> g.tagTransition('B', 'down2', 'two')
5532        >>> g.mergeTransitions('B', 'down2', 'down')
5533        >>> g.getDestination('A', 'up2') is None
5534        True
5535        >>> g.getDestination('A', 'up')
5536        1
5537        >>> g.getDestination('B', 'down2') is None
5538        True
5539        >>> g.getDestination('B', 'down')
5540        0
5541        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
5542        >>> g.addTransition('A', 'up2', 'B')
5543        >>> g.setTransitionProperties(
5544        ...     'A',
5545        ...     'up2',
5546        ...     requirement=base.ReqCapability('dash')
5547        ... )
5548        >>> g.setTransitionProperties('A', 'up',
5549        ...     requirement=base.ReqCapability('slide'))
5550        >>> g.mergeTransitions('A', 'up2', 'up')
5551        >>> g.getDestination('A', 'up2') is None
5552        True
5553        >>> repr(g.getTransitionRequirement('A', 'up'))
5554        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
5555        >>> # Errors if destinations differ, or if something is missing
5556        >>> g.mergeTransitions('A', 'down', 'up')
5557        Traceback (most recent call last):
5558        ...
5559        exploration.core.MissingTransitionError...
5560        >>> g.mergeTransitions('Z', 'one', 'two')
5561        Traceback (most recent call last):
5562        ...
5563        exploration.core.MissingDecisionError...
5564        >>> g.addDecision('C')
5565        2
5566        >>> g.addTransition('A', 'down', 'C')
5567        >>> g.mergeTransitions('A', 'down', 'up')
5568        Traceback (most recent call last):
5569        ...
5570        exploration.core.InvalidDestinationError...
5571        >>> # Merging a reciprocal onto an edge that doesn't have one
5572        >>> g.addTransition('A', 'down2', 'C')
5573        >>> g.addTransition('C', 'up2', 'A')
5574        >>> g.setReciprocal('A', 'down2', 'up2')
5575        >>> g.tagTransition('C', 'up2', 'narrow')
5576        >>> g.getReciprocal('A', 'down') is None
5577        True
5578        >>> g.mergeTransitions('A', 'down2', 'down')
5579        >>> g.getDestination('A', 'down2') is None
5580        True
5581        >>> g.getDestination('A', 'down')
5582        2
5583        >>> g.getDestination('C', 'up2')
5584        0
5585        >>> g.getReciprocal('A', 'down')
5586        'up2'
5587        >>> g.getReciprocal('C', 'up2')
5588        'down'
5589        >>> g.transitionTags('C', 'up2')
5590        {'narrow': 1}
5591        >>> # Merging without a reciprocal
5592        >>> g.addTransition('C', 'up', 'A')
5593        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
5594        >>> g.getDestination('C', 'up2') is None
5595        True
5596        >>> g.getDestination('C', 'up')
5597        0
5598        >>> g.transitionTags('C', 'up') # tag gets merged
5599        {'narrow': 1}
5600        >>> g.getDestination('A', 'down')
5601        2
5602        >>> g.getReciprocal('A', 'down') is None
5603        True
5604        >>> g.getReciprocal('C', 'up') is None
5605        True
5606        >>> # Merging w/ normal reciprocals
5607        >>> g.addDecision('D')
5608        3
5609        >>> g.addDecision('E')
5610        4
5611        >>> g.addTransition('D', 'up', 'E', 'return')
5612        >>> g.addTransition('E', 'down', 'D')
5613        >>> g.mergeTransitions('E', 'return', 'down')
5614        >>> g.getDestination('D', 'up')
5615        4
5616        >>> g.getDestination('E', 'down')
5617        3
5618        >>> g.getDestination('E', 'return') is None
5619        True
5620        >>> g.getReciprocal('D', 'up')
5621        'down'
5622        >>> g.getReciprocal('E', 'down')
5623        'up'
5624        >>> # Merging w/ weird reciprocals
5625        >>> g.addTransition('E', 'return', 'D')
5626        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
5627        >>> g.getReciprocal('D', 'up')
5628        'down'
5629        >>> g.getReciprocal('E', 'down')
5630        'up'
5631        >>> g.getReciprocal('E', 'return') # shared
5632        'up'
5633        >>> g.mergeTransitions('E', 'return', 'down')
5634        >>> g.getDestination('D', 'up')
5635        4
5636        >>> g.getDestination('E', 'down')
5637        3
5638        >>> g.getDestination('E', 'return') is None
5639        True
5640        >>> g.getReciprocal('D', 'up')
5641        'down'
5642        >>> g.getReciprocal('E', 'down')
5643        'up'
5644        """
5645        fromID = self.resolveDecision(fromDecision)
5646
5647        # Short-circuit in the no-op case
5648        if merge == mergeInto:
5649            return
5650
5651        # These lines will raise a MissingDecisionError or
5652        # MissingTransitionError if needed
5653        dest1 = self.destination(fromID, merge)
5654        dest2 = self.destination(fromID, mergeInto)
5655
5656        if dest1 != dest2:
5657            raise InvalidDestinationError(
5658                f"Cannot merge transition {merge!r} into transition"
5659                f" {mergeInto!r} from decision"
5660                f" {self.identityOf(fromDecision)} because their"
5661                f" destinations are different ({self.identityOf(dest1)}"
5662                f" and {self.identityOf(dest2)}).\nNote: you can use"
5663                f" `retargetTransition` to change the destination of a"
5664                f" transition."
5665            )
5666
5667        # Find and the transition properties
5668        props1 = self.getTransitionProperties(fromID, merge)
5669        props2 = self.getTransitionProperties(fromID, mergeInto)
5670        merged = mergeProperties(props1, props2)
5671        # Note that this doesn't change the reciprocal:
5672        self.setTransitionProperties(fromID, mergeInto, **merged)
5673
5674        # Merge the reciprocal properties if requested
5675        # Get reciprocal to merge into
5676        reciprocal = self.getReciprocal(fromID, mergeInto)
5677        # Get reciprocal that needs cleaning up
5678        altReciprocal = self.getReciprocal(fromID, merge)
5679        # If the reciprocal to be merged actually already was the
5680        # reciprocal to merge into, there's nothing to do here
5681        if altReciprocal != reciprocal:
5682            if not mergeReciprocal:
5683                # In this case, we sever the reciprocal relationship if
5684                # there is a reciprocal
5685                if altReciprocal is not None:
5686                    self.setReciprocal(dest1, altReciprocal, None)
5687                    # By default setBoth takes care of the other half
5688            else:
5689                # In this case, we try to merge reciprocals
5690                # If altReciprocal is None, we don't need to do anything
5691                if altReciprocal is not None:
5692                    # Was there already a reciprocal or not?
5693                    if reciprocal is None:
5694                        # altReciprocal becomes the new reciprocal and is
5695                        # not deleted
5696                        self.setReciprocal(
5697                            fromID,
5698                            mergeInto,
5699                            altReciprocal
5700                        )
5701                    else:
5702                        # merge reciprocal properties
5703                        props1 = self.getTransitionProperties(
5704                            dest1,
5705                            altReciprocal
5706                        )
5707                        props2 = self.getTransitionProperties(
5708                            dest2,
5709                            reciprocal
5710                        )
5711                        merged = mergeProperties(props1, props2)
5712                        self.setTransitionProperties(
5713                            dest1,
5714                            reciprocal,
5715                            **merged
5716                        )
5717
5718                        # delete the old reciprocal transition
5719                        self.remove_edge(dest1, fromID, altReciprocal)
5720
5721        # Delete the old transition (reciprocal deletion/severance is
5722        # handled above if necessary)
5723        self.remove_edge(fromID, dest1, merge)

Given a decision and two transitions that start at that decision, merges the first transition into the second transition, combining their transition properties (using mergeProperties) and deleting the first transition. By default any reciprocal of the first transition is also merged into the reciprocal of the second, although you can set mergeReciprocal to False to disable this in which case the old reciprocal will lose its reciprocal relationship, even if the transition that was merged into does not have a reciprocal.

If the two names provided are the same, nothing will happen.

If the two transitions do not share the same destination, they cannot be merged, and an InvalidDestinationError will result. Use retargetTransition beforehand to ensure that they do if you want to merge transitions with different destinations.

A MissingDecisionError or MissingTransitionError will result if the decision or either transition does not exist.

If merging reciprocal properties was requested and the first transition does not have a reciprocal, then no reciprocal properties change. However, if the second transition does not have a reciprocal and the first does, the first transition's reciprocal will be set to the reciprocal of the second transition, and that transition will not be deleted as usual.

Example

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addDecision('B')
1
>>> g.addTransition('A', 'up', 'B')
>>> g.addTransition('B', 'down', 'A')
>>> g.setReciprocal('A', 'up', 'down')
>>> # Merging a transition with no reciprocal
>>> g.addTransition('A', 'up2', 'B')
>>> g.mergeTransitions('A', 'up2', 'up')
>>> g.getDestination('A', 'up2') is None
True
>>> g.getDestination('A', 'up')
1
>>> # Merging a transition with a reciprocal & tags
>>> g.addTransition('A', 'up2', 'B')
>>> g.addTransition('B', 'down2', 'A')
>>> g.setReciprocal('A', 'up2', 'down2')
>>> g.tagTransition('A', 'up2', 'one')
>>> g.tagTransition('B', 'down2', 'two')
>>> g.mergeTransitions('B', 'down2', 'down')
>>> g.getDestination('A', 'up2') is None
True
>>> g.getDestination('A', 'up')
1
>>> g.getDestination('B', 'down2') is None
True
>>> g.getDestination('B', 'down')
0
>>> # Merging requirements uses ReqAll (i.e., 'and' logic)
>>> g.addTransition('A', 'up2', 'B')
>>> g.setTransitionProperties(
...     'A',
...     'up2',
...     requirement=base.ReqCapability('dash')
... )
>>> g.setTransitionProperties('A', 'up',
...     requirement=base.ReqCapability('slide'))
>>> g.mergeTransitions('A', 'up2', 'up')
>>> g.getDestination('A', 'up2') is None
True
>>> repr(g.getTransitionRequirement('A', 'up'))
"ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
>>> # Errors if destinations differ, or if something is missing
>>> g.mergeTransitions('A', 'down', 'up')
Traceback (most recent call last):
...
MissingTransitionError...
>>> g.mergeTransitions('Z', 'one', 'two')
Traceback (most recent call last):
...
MissingDecisionError...
>>> g.addDecision('C')
2
>>> g.addTransition('A', 'down', 'C')
>>> g.mergeTransitions('A', 'down', 'up')
Traceback (most recent call last):
...
InvalidDestinationError...
>>> # Merging a reciprocal onto an edge that doesn't have one
>>> g.addTransition('A', 'down2', 'C')
>>> g.addTransition('C', 'up2', 'A')
>>> g.setReciprocal('A', 'down2', 'up2')
>>> g.tagTransition('C', 'up2', 'narrow')
>>> g.getReciprocal('A', 'down') is None
True
>>> g.mergeTransitions('A', 'down2', 'down')
>>> g.getDestination('A', 'down2') is None
True
>>> g.getDestination('A', 'down')
2
>>> g.getDestination('C', 'up2')
0
>>> g.getReciprocal('A', 'down')
'up2'
>>> g.getReciprocal('C', 'up2')
'down'
>>> g.transitionTags('C', 'up2')
{'narrow': 1}
>>> # Merging without a reciprocal
>>> g.addTransition('C', 'up', 'A')
>>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
>>> g.getDestination('C', 'up2') is None
True
>>> g.getDestination('C', 'up')
0
>>> g.transitionTags('C', 'up') # tag gets merged
{'narrow': 1}
>>> g.getDestination('A', 'down')
2
>>> g.getReciprocal('A', 'down') is None
True
>>> g.getReciprocal('C', 'up') is None
True
>>> # Merging w/ normal reciprocals
>>> g.addDecision('D')
3
>>> g.addDecision('E')
4
>>> g.addTransition('D', 'up', 'E', 'return')
>>> g.addTransition('E', 'down', 'D')
>>> g.mergeTransitions('E', 'return', 'down')
>>> g.getDestination('D', 'up')
4
>>> g.getDestination('E', 'down')
3
>>> g.getDestination('E', 'return') is None
True
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
>>> # Merging w/ weird reciprocals
>>> g.addTransition('E', 'return', 'D')
>>> g.setReciprocal('E', 'return', 'up', setBoth=False)
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
>>> g.getReciprocal('E', 'return') # shared
'up'
>>> g.mergeTransitions('E', 'return', 'down')
>>> g.getDestination('D', 'up')
4
>>> g.getDestination('E', 'down')
3
>>> g.getDestination('E', 'return') is None
True
>>> g.getReciprocal('D', 'up')
'down'
>>> g.getReciprocal('E', 'down')
'up'
def isConfirmed( self, decision: Union[int, exploration.base.DecisionSpecifier, str]) -> bool:
5725    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
5726        """
5727        Returns `True` or `False` depending on whether or not the
5728        specified decision has been confirmed. Uses the presence or
5729        absence of the 'unconfirmed' tag to determine this.
5730
5731        Note: 'unconfirmed' is used instead of 'confirmed' so that large
5732        graphs with many confirmed nodes will be smaller when saved.
5733        """
5734        dID = self.resolveDecision(decision)
5735
5736        return 'unconfirmed' not in self.nodes[dID]['tags']

Returns True or False depending on whether or not the specified decision has been confirmed. Uses the presence or absence of the 'unconfirmed' tag to determine this.

Note: 'unconfirmed' is used instead of 'confirmed' so that large graphs with many confirmed nodes will be smaller when saved.

def replaceUnconfirmed( self, fromDecision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, connectTo: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, reciprocal: Optional[str] = None, requirement: Optional[exploration.base.Requirement] = None, applyConsequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, placeInZone: Optional[str] = None, forceNew: bool = False, tags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, annotations: Optional[List[str]] = None, revRequires: Optional[exploration.base.Requirement] = None, revConsequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, revTags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, revAnnotations: Optional[List[str]] = None, decisionTags: Optional[Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]] = None, decisionAnnotations: Optional[List[str]] = None) -> Tuple[Dict[str, str], Dict[str, str]]:
5738    def replaceUnconfirmed(
5739        self,
5740        fromDecision: base.AnyDecisionSpecifier,
5741        transition: base.Transition,
5742        connectTo: Optional[base.AnyDecisionSpecifier] = None,
5743        reciprocal: Optional[base.Transition] = None,
5744        requirement: Optional[base.Requirement] = None,
5745        applyConsequence: Optional[base.Consequence] = None,
5746        placeInZone: Optional[base.Zone] = None,
5747        forceNew: bool = False,
5748        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
5749        annotations: Optional[List[base.Annotation]] = None,
5750        revRequires: Optional[base.Requirement] = None,
5751        revConsequence: Optional[base.Consequence] = None,
5752        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5753        revAnnotations: Optional[List[base.Annotation]] = None,
5754        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5755        decisionAnnotations: Optional[List[base.Annotation]] = None
5756    ) -> Tuple[
5757        Dict[base.Transition, base.Transition],
5758        Dict[base.Transition, base.Transition]
5759    ]:
5760        """
5761        Given a decision and an edge name in that decision, where the
5762        named edge leads to a decision with an unconfirmed exploration
5763        state (see `isConfirmed`), renames the unexplored decision on
5764        the other end of that edge using the given `connectTo` name, or
5765        if a decision using that name already exists, merges the
5766        unexplored decision into that decision. If `connectTo` is a
5767        `DecisionSpecifier` whose target doesn't exist, it will be
5768        treated as just a name, but if it's an ID and it doesn't exist,
5769        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
5770        a reciprocal edge will be added using that name connecting the
5771        `connectTo` decision back to the original decision. If this
5772        transition already exists, it must also point to a node which is
5773        also unexplored, and which will also be merged into the
5774        `fromDecision` node.
5775
5776        If `connectTo` is not given (or is set to `None` explicitly)
5777        then the name of the unexplored decision will not be changed,
5778        unless that name has the form `'_u.-n-'` where `-n-` is a positive
5779        integer (i.e., the form given to automatically-named unknown
5780        nodes). In that case, the name will be changed to `'_x.-n-'` using
5781        the same number, or a higher number if that name is already taken.
5782
5783        If the destination is being renamed or if the destination's
5784        exploration state counts as unexplored, the exploration state of
5785        the destination will be set to 'exploring'.
5786
5787        If a `placeInZone` is specified, the destination will be placed
5788        directly into that zone (even if it already existed and has zone
5789        information), and it will be removed from any other zones it had
5790        been a direct member of. If `placeInZone` is set to
5791        `base.DefaultZone`, then the destination will be placed into
5792        each zone which is a direct parent of the origin, but only if
5793        the destination is not an already-explored existing decision AND
5794        it is not already in any zones (in those cases no zone changes
5795        are made). This will also remove it from any previous zones it
5796        had been a part of. If `placeInZone` is left as `None` (the
5797        default) no zone changes are made.
5798
5799        If `placeInZone` is specified and that zone didn't already exist,
5800        it will be created as a new level-0 zone and will be added as a
5801        sub-zone of each zone that's a direct parent of any level-0 zone
5802        that the origin is a member of.
5803
5804        If `forceNew` is specified, then the destination will just be
5805        renamed, even if another decision with the same name already
5806        exists. It's an error to use `forceNew` with a decision ID as
5807        the destination.
5808
5809        Any additional edges pointing to or from the unknown node(s)
5810        being replaced will also be re-targeted at the now-discovered
5811        known destination(s) if necessary. These edges will retain their
5812        reciprocal names, or if this would cause a name clash, they will
5813        be renamed with a suffix (see `retargetTransition`).
5814
5815        The return value is a pair of dictionaries mapping old names to
5816        new ones that just includes the names which were changed. The
5817        first dictionary contains renamed transitions that are outgoing
5818        from the new destination node (which used to be outgoing from
5819        the unexplored node). The second dictionary contains renamed
5820        transitions that are outgoing from the source node (which used
5821        to be outgoing from the unexplored node attached to the
5822        reciprocal transition; if there was no reciprocal transition
5823        specified then this will always be an empty dictionary).
5824
5825        An `ExplorationStatusError` will be raised if the destination
5826        of the specified transition counts as visited (see
5827        `hasBeenVisited`). An `ExplorationStatusError` will also be
5828        raised if the `connectTo`'s `reciprocal` transition does not lead
5829        to an unconfirmed decision (it's okay if this second transition
5830        doesn't exist). A `TransitionCollisionError` will be raised if
5831        the unconfirmed destination decision already has an outgoing
5832        transition with the specified `reciprocal` which does not lead
5833        back to the `fromDecision`.
5834
5835        The transition properties (requirement, consequences, tags,
5836        and/or annotations) of the replaced transition will be copied
5837        over to the new transition. Transition properties from the
5838        reciprocal transition will also be copied for the newly created
5839        reciprocal edge. Properties for any additional edges to/from the
5840        unknown node will also be copied.
5841
5842        Also, any transition properties on existing forward or reciprocal
5843        edges from the destination node with the indicated reverse name
5844        will be merged with those from the target transition. Note that
5845        this merging process may introduce corruption of complex
5846        transition consequences. TODO: Fix that!
5847
5848        Any tags and annotations are added to copied tags/annotations,
5849        but specified requirements, and/or consequences will replace
5850        previous requirements/consequences, rather than being added to
5851        them.
5852
5853        ## Example
5854
5855        >>> g = DecisionGraph()
5856        >>> g.addDecision('A')
5857        0
5858        >>> g.addUnexploredEdge('A', 'up')
5859        1
5860        >>> g.destination('A', 'up')
5861        1
5862        >>> g.destination('_u.0', 'return')
5863        0
5864        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
5865        ({}, {})
5866        >>> g.destination('A', 'up')
5867        1
5868        >>> g.nameFor(1)
5869        'B'
5870        >>> g.destination('B', 'down')
5871        0
5872        >>> g.getDestination('B', 'return') is None
5873        True
5874        >>> '_u.0' in g.nameLookup
5875        False
5876        >>> g.getReciprocal('A', 'up')
5877        'down'
5878        >>> g.getReciprocal('B', 'down')
5879        'up'
5880        >>> # Two unexplored edges to the same node:
5881        >>> g.addDecision('C')
5882        2
5883        >>> g.addTransition('B', 'next', 'C')
5884        >>> g.addTransition('C', 'prev', 'B')
5885        >>> g.setReciprocal('B', 'next', 'prev')
5886        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
5887        3
5888        >>> g.addTransition('C', 'down', 'D')
5889        >>> g.addTransition('D', 'up', 'C')
5890        >>> g.setReciprocal('C', 'down', 'up')
5891        >>> g.replaceUnconfirmed('C', 'down')
5892        ({}, {})
5893        >>> g.destination('C', 'down')
5894        3
5895        >>> g.destination('A', 'next')
5896        3
5897        >>> g.destinationsFrom('D')
5898        {'prev': 0, 'up': 2}
5899        >>> g.decisionTags('D')
5900        {}
5901        >>> # An unexplored transition which turns out to connect to a
5902        >>> # known decision, with name collisions
5903        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
5904        4
5905        >>> g.tagDecision('_u.2', 'wet')
5906        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
5907        Traceback (most recent call last):
5908        ...
5909        exploration.core.TransitionCollisionError...
5910        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
5911        5
5912        >>> g.tagDecision('_u.3', 'dry')
5913        >>> # Add transitions that will collide when merged
5914        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
5915        6
5916        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
5917        7
5918        >>> g.getReciprocal('A', 'prev')
5919        'next'
5920        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
5921        ({'prev': 'prev.1'}, {'up': 'up.1'})
5922        >>> g.destination('A', 'prev')
5923        3
5924        >>> g.destination('D', 'next')
5925        0
5926        >>> g.getReciprocal('A', 'prev')
5927        'next'
5928        >>> g.getReciprocal('D', 'next')
5929        'prev'
5930        >>> # Note that further unexplored structures are NOT merged
5931        >>> # even if they match against existing structures...
5932        >>> g.destination('A', 'up.1')
5933        6
5934        >>> g.destination('D', 'prev.1')
5935        7
5936        >>> '_u.2' in g.nameLookup
5937        False
5938        >>> '_u.3' in g.nameLookup
5939        False
5940        >>> g.decisionTags('D') # tags are merged
5941        {'dry': 1}
5942        >>> g.decisionTags('A')
5943        {'wet': 1}
5944        >>> # Auto-renaming an anonymous unexplored node
5945        >>> g.addUnexploredEdge('B', 'out')
5946        8
5947        >>> g.replaceUnconfirmed('B', 'out')
5948        ({}, {})
5949        >>> '_u.6' in g
5950        False
5951        >>> g.destination('B', 'out')
5952        8
5953        >>> g.nameFor(8)
5954        '_x.6'
5955        >>> g.destination('_x.6', 'return')
5956        1
5957        >>> # Placing a node into a zone
5958        >>> g.addUnexploredEdge('B', 'through')
5959        9
5960        >>> g.getDecision('E') is None
5961        True
5962        >>> g.replaceUnconfirmed(
5963        ...     'B',
5964        ...     'through',
5965        ...     'E',
5966        ...     'back',
5967        ...     placeInZone='Zone'
5968        ... )
5969        ({}, {})
5970        >>> g.getDecision('E')
5971        9
5972        >>> g.destination('B', 'through')
5973        9
5974        >>> g.destination('E', 'back')
5975        1
5976        >>> g.zoneParents(9)
5977        {'Zone'}
5978        >>> g.addUnexploredEdge('E', 'farther')
5979        10
5980        >>> g.replaceUnconfirmed(
5981        ...     'E',
5982        ...     'farther',
5983        ...     'F',
5984        ...     'closer',
5985        ...     placeInZone=base.DefaultZone
5986        ... )
5987        ({}, {})
5988        >>> g.destination('E', 'farther')
5989        10
5990        >>> g.destination('F', 'closer')
5991        9
5992        >>> g.zoneParents(10)
5993        {'Zone'}
5994        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
5995        11
5996        >>> g.replaceUnconfirmed(
5997        ...     'F',
5998        ...     'backwards',
5999        ...     'G',
6000        ...     'forwards',
6001        ...     placeInZone=base.DefaultZone
6002        ... )
6003        ({}, {})
6004        >>> g.destination('F', 'backwards')
6005        11
6006        >>> g.destination('G', 'forwards')
6007        10
6008        >>> g.zoneParents(11)  # not changed since it already had a zone
6009        {'Enoz'}
6010        >>> # TODO: forceNew example
6011        """
6012
6013        # Defaults
6014        if tags is None:
6015            tags = {}
6016        if annotations is None:
6017            annotations = []
6018        if revTags is None:
6019            revTags = {}
6020        if revAnnotations is None:
6021            revAnnotations = []
6022        if decisionTags is None:
6023            decisionTags = {}
6024        if decisionAnnotations is None:
6025            decisionAnnotations = []
6026
6027        # Resolve source
6028        fromID = self.resolveDecision(fromDecision)
6029
6030        # Figure out destination decision
6031        oldUnexplored = self.destination(fromID, transition)
6032        if self.isConfirmed(oldUnexplored):
6033            raise ExplorationStatusError(
6034                f"Transition {transition!r} from"
6035                f" {self.identityOf(fromDecision)} does not lead to an"
6036                f" unconfirmed decision (it leads to"
6037                f" {self.identityOf(oldUnexplored)} which is not tagged"
6038                f" 'unconfirmed')."
6039            )
6040
6041        # Resolve destination
6042        newName: Optional[base.DecisionName] = None
6043        connectID: Optional[base.DecisionID] = None
6044        if forceNew:
6045            if isinstance(connectTo, base.DecisionID):
6046                raise TypeError(
6047                    f"connectTo cannot be a decision ID when forceNew"
6048                    f" is True. Got: {self.identityOf(connectTo)}"
6049                )
6050            elif isinstance(connectTo, base.DecisionSpecifier):
6051                newName = connectTo.name
6052            elif isinstance(connectTo, base.DecisionName):
6053                newName = connectTo
6054            elif connectTo is None:
6055                oldName = self.nameFor(oldUnexplored)
6056                if (
6057                    oldName.startswith('_u.')
6058                and oldName[3:].isdigit()
6059                ):
6060                    newName = utils.uniqueName('_x.' + oldName[3:], self)
6061                else:
6062                    newName = oldName
6063            else:
6064                raise TypeError(
6065                    f"Invalid connectTo value: {connectTo!r}"
6066                )
6067        elif connectTo is not None:
6068            try:
6069                connectID = self.resolveDecision(connectTo)
6070                # leave newName as None
6071            except MissingDecisionError:
6072                if isinstance(connectTo, int):
6073                    raise
6074                elif isinstance(connectTo, base.DecisionSpecifier):
6075                    newName = connectTo.name
6076                    # The domain & zone are ignored here
6077                else:  # Must just be a string
6078                    assert isinstance(connectTo, str)
6079                    newName = connectTo
6080        else:
6081            # If connectTo name wasn't specified, use current name of
6082            # unknown node unless it's a default name
6083            oldName = self.nameFor(oldUnexplored)
6084            if (
6085                oldName.startswith('_u.')
6086            and oldName[3:].isdigit()
6087            ):
6088                newName = utils.uniqueName('_x.' + oldName[3:], self)
6089            else:
6090                newName = oldName
6091
6092        # One or the other should be valid at this point
6093        assert connectID is not None or newName is not None
6094
6095        # Check that the old unknown doesn't have a reciprocal edge that
6096        # would collide with the specified return edge
6097        if reciprocal is not None:
6098            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
6099            if revFromUnknown not in (None, fromID):
6100                raise TransitionCollisionError(
6101                    f"Transition {reciprocal!r} from"
6102                    f" {self.identityOf(oldUnexplored)} exists and does"
6103                    f" not lead back to {self.identityOf(fromDecision)}"
6104                    f" (it leads to {self.identityOf(revFromUnknown)})."
6105                )
6106
6107        # Remember old reciprocal edge for future merging in case
6108        # it's not reciprocal
6109        oldReciprocal = self.getReciprocal(fromID, transition)
6110
6111        # Apply any new tags or annotations, or create a new node
6112        needsZoneInfo = False
6113        if connectID is not None:
6114            # Before applying tags, check if we need to error out
6115            # because of a reciprocal edge that points to a known
6116            # destination:
6117            if reciprocal is not None:
6118                otherOldUnknown: Optional[
6119                    base.DecisionID
6120                ] = self.getDestination(
6121                    connectID,
6122                    reciprocal
6123                )
6124                if (
6125                    otherOldUnknown is not None
6126                and self.isConfirmed(otherOldUnknown)
6127                ):
6128                    raise ExplorationStatusError(
6129                        f"Reciprocal transition {reciprocal!r} from"
6130                        f" {self.identityOf(connectTo)} does not lead"
6131                        f" to an unconfirmed decision (it leads to"
6132                        f" {self.identityOf(otherOldUnknown)})."
6133                    )
6134            self.tagDecision(connectID, decisionTags)
6135            self.annotateDecision(connectID, decisionAnnotations)
6136            # Still needs zone info if the place we're connecting to was
6137            # unconfirmed up until now, since unconfirmed nodes don't
6138            # normally get zone info when they're created.
6139            if not self.isConfirmed(connectID):
6140                needsZoneInfo = True
6141
6142            # First, merge the old unknown with the connectTo node...
6143            destRenames = self.mergeDecisions(
6144                oldUnexplored,
6145                connectID,
6146                errorOnNameColision=False
6147            )
6148        else:
6149            needsZoneInfo = True
6150            if len(self.zoneParents(oldUnexplored)) > 0:
6151                needsZoneInfo = False
6152            assert newName is not None
6153            self.renameDecision(oldUnexplored, newName)
6154            connectID = oldUnexplored
6155            # In this case there can't be an other old unknown
6156            otherOldUnknown = None
6157            destRenames = {}  # empty
6158
6159        # Check for domain mismatch to stifle zone updates:
6160        fromDomain = self.domainFor(fromID)
6161        if connectID is None:
6162            destDomain = self.domainFor(oldUnexplored)
6163        else:
6164            destDomain = self.domainFor(connectID)
6165
6166        # Stifle zone updates if there's a mismatch
6167        if fromDomain != destDomain:
6168            needsZoneInfo = False
6169
6170        # Records renames that happen at the source (from node)
6171        sourceRenames = {}  # empty for now
6172
6173        assert connectID is not None
6174
6175        # Apply the new zone if there is one
6176        if placeInZone is not None:
6177            if placeInZone == base.DefaultZone:
6178                # When using DefaultZone, changes are only made for new
6179                # destinations which don't already have any zones and
6180                # which are in the same domain as the departing node:
6181                # they get placed into each zone parent of the source
6182                # decision.
6183                if needsZoneInfo:
6184                    # Remove destination from all current parents
6185                    removeFrom = set(self.zoneParents(connectID))  # copy
6186                    for parent in removeFrom:
6187                        self.removeDecisionFromZone(connectID, parent)
6188                    # Add it to parents of origin
6189                    for parent in self.zoneParents(fromID):
6190                        self.addDecisionToZone(connectID, parent)
6191            else:
6192                placeInZone = cast(base.Zone, placeInZone)
6193                # Create the zone if it doesn't already exist
6194                if self.getZoneInfo(placeInZone) is None:
6195                    self.createZone(placeInZone, 0)
6196                    # Add it to each grandparent of the from decision
6197                    for parent in self.zoneParents(fromID):
6198                        for grandparent in self.zoneParents(parent):
6199                            self.addZoneToZone(placeInZone, grandparent)
6200                # Remove destination from all current parents
6201                for parent in set(self.zoneParents(connectID)):
6202                    self.removeDecisionFromZone(connectID, parent)
6203                # Add it to the specified zone
6204                self.addDecisionToZone(connectID, placeInZone)
6205
6206        # Next, if there is a reciprocal name specified, we do more...
6207        if reciprocal is not None:
6208            # Figure out what kind of merging needs to happen
6209            if otherOldUnknown is None:
6210                if revFromUnknown is None:
6211                    # Just create the desired reciprocal transition, which
6212                    # we know does not already exist
6213                    self.addTransition(connectID, reciprocal, fromID)
6214                    otherOldReciprocal = None
6215                else:
6216                    # Reciprocal exists, as revFromUnknown
6217                    otherOldReciprocal = None
6218            else:
6219                otherOldReciprocal = self.getReciprocal(
6220                    connectID,
6221                    reciprocal
6222                )
6223                # we need to merge otherOldUnknown into our fromDecision
6224                sourceRenames = self.mergeDecisions(
6225                    otherOldUnknown,
6226                    fromID,
6227                    errorOnNameColision=False
6228                )
6229                # Unvisited tag after merge only if both were
6230
6231            # No matter what happened we ensure the reciprocal
6232            # relationship is set up:
6233            self.setReciprocal(fromID, transition, reciprocal)
6234
6235            # Now we might need to merge some transitions:
6236            # - Any reciprocal of the target transition should be merged
6237            #   with reciprocal (if it was already reciprocal, that's a
6238            #   no-op).
6239            # - Any reciprocal of the reciprocal transition from the target
6240            #   node (leading to otherOldUnknown) should be merged with
6241            #   the target transition, even if it shared a name and was
6242            #   renamed as a result.
6243            # - If reciprocal was renamed during the initial merge, those
6244            #   transitions should be merged.
6245
6246            # Merge old reciprocal into reciprocal
6247            if oldReciprocal is not None:
6248                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
6249                if self.getDestination(connectID, oldRev) is not None:
6250                    # Note that we don't want to auto-merge the reciprocal,
6251                    # which is the target transition
6252                    self.mergeTransitions(
6253                        connectID,
6254                        oldRev,
6255                        reciprocal,
6256                        mergeReciprocal=False
6257                    )
6258                    # Remove it from the renames map
6259                    if oldReciprocal in destRenames:
6260                        del destRenames[oldReciprocal]
6261
6262            # Merge reciprocal reciprocal from otherOldUnknown
6263            if otherOldReciprocal is not None:
6264                otherOldRev = sourceRenames.get(
6265                    otherOldReciprocal,
6266                    otherOldReciprocal
6267                )
6268                # Note that the reciprocal is reciprocal, which we don't
6269                # need to merge
6270                self.mergeTransitions(
6271                    fromID,
6272                    otherOldRev,
6273                    transition,
6274                    mergeReciprocal=False
6275                )
6276                # Remove it from the renames map
6277                if otherOldReciprocal in sourceRenames:
6278                    del sourceRenames[otherOldReciprocal]
6279
6280            # Merge any renamed reciprocal onto reciprocal
6281            if reciprocal in destRenames:
6282                extraRev = destRenames[reciprocal]
6283                self.mergeTransitions(
6284                    connectID,
6285                    extraRev,
6286                    reciprocal,
6287                    mergeReciprocal=False
6288                )
6289                # Remove it from the renames map
6290                del destRenames[reciprocal]
6291
6292        # Accumulate new tags & annotations for the transitions
6293        self.tagTransition(fromID, transition, tags)
6294        self.annotateTransition(fromID, transition, annotations)
6295
6296        if reciprocal is not None:
6297            self.tagTransition(connectID, reciprocal, revTags)
6298            self.annotateTransition(connectID, reciprocal, revAnnotations)
6299
6300        # Override copied requirement/consequences for the transitions
6301        if requirement is not None:
6302            self.setTransitionRequirement(
6303                fromID,
6304                transition,
6305                requirement
6306            )
6307        if applyConsequence is not None:
6308            self.setConsequence(
6309                fromID,
6310                transition,
6311                applyConsequence
6312            )
6313
6314        if reciprocal is not None:
6315            if revRequires is not None:
6316                self.setTransitionRequirement(
6317                    connectID,
6318                    reciprocal,
6319                    revRequires
6320                )
6321            if revConsequence is not None:
6322                self.setConsequence(
6323                    connectID,
6324                    reciprocal,
6325                    revConsequence
6326                )
6327
6328        # Remove 'unconfirmed' tag if it was present
6329        self.untagDecision(connectID, 'unconfirmed')
6330
6331        # Final checks
6332        assert self.getDestination(fromDecision, transition) == connectID
6333        useConnect: base.AnyDecisionSpecifier
6334        useRev: Optional[str]
6335        if connectTo is None:
6336            useConnect = connectID
6337        else:
6338            useConnect = connectTo
6339        if reciprocal is None:
6340            useRev = self.getReciprocal(fromDecision, transition)
6341        else:
6342            useRev = reciprocal
6343        if useRev is not None:
6344            try:
6345                assert self.getDestination(useConnect, useRev) == fromID
6346            except AmbiguousDecisionSpecifierError:
6347                assert self.getDestination(connectID, useRev) == fromID
6348
6349        # Return our final rename dictionaries
6350        return (destRenames, sourceRenames)

Given a decision and an edge name in that decision, where the named edge leads to a decision with an unconfirmed exploration state (see isConfirmed), renames the unexplored decision on the other end of that edge using the given connectTo name, or if a decision using that name already exists, merges the unexplored decision into that decision. If connectTo is a DecisionSpecifier whose target doesn't exist, it will be treated as just a name, but if it's an ID and it doesn't exist, you'll get a MissingDecisionError. If a reciprocal is provided, a reciprocal edge will be added using that name connecting the connectTo decision back to the original decision. If this transition already exists, it must also point to a node which is also unexplored, and which will also be merged into the fromDecision node.

If connectTo is not given (or is set to None explicitly) then the name of the unexplored decision will not be changed, unless that name has the form '_u.-n-' where -n- is a positive integer (i.e., the form given to automatically-named unknown nodes). In that case, the name will be changed to '_x.-n-' using the same number, or a higher number if that name is already taken.

If the destination is being renamed or if the destination's exploration state counts as unexplored, the exploration state of the destination will be set to 'exploring'.

If a placeInZone is specified, the destination will be placed directly into that zone (even if it already existed and has zone information), and it will be removed from any other zones it had been a direct member of. If placeInZone is set to base.DefaultZone, then the destination will be placed into each zone which is a direct parent of the origin, but only if the destination is not an already-explored existing decision AND it is not already in any zones (in those cases no zone changes are made). This will also remove it from any previous zones it had been a part of. If placeInZone is left as None (the default) no zone changes are made.

If placeInZone is specified and that zone didn't already exist, it will be created as a new level-0 zone and will be added as a sub-zone of each zone that's a direct parent of any level-0 zone that the origin is a member of.

If forceNew is specified, then the destination will just be renamed, even if another decision with the same name already exists. It's an error to use forceNew with a decision ID as the destination.

Any additional edges pointing to or from the unknown node(s) being replaced will also be re-targeted at the now-discovered known destination(s) if necessary. These edges will retain their reciprocal names, or if this would cause a name clash, they will be renamed with a suffix (see retargetTransition).

The return value is a pair of dictionaries mapping old names to new ones that just includes the names which were changed. The first dictionary contains renamed transitions that are outgoing from the new destination node (which used to be outgoing from the unexplored node). The second dictionary contains renamed transitions that are outgoing from the source node (which used to be outgoing from the unexplored node attached to the reciprocal transition; if there was no reciprocal transition specified then this will always be an empty dictionary).

An ExplorationStatusError will be raised if the destination of the specified transition counts as visited (see hasBeenVisited). An ExplorationStatusError will also be raised if the connectTo's reciprocal transition does not lead to an unconfirmed decision (it's okay if this second transition doesn't exist). A TransitionCollisionError will be raised if the unconfirmed destination decision already has an outgoing transition with the specified reciprocal which does not lead back to the fromDecision.

The transition properties (requirement, consequences, tags, and/or annotations) of the replaced transition will be copied over to the new transition. Transition properties from the reciprocal transition will also be copied for the newly created reciprocal edge. Properties for any additional edges to/from the unknown node will also be copied.

Also, any transition properties on existing forward or reciprocal edges from the destination node with the indicated reverse name will be merged with those from the target transition. Note that this merging process may introduce corruption of complex transition consequences. TODO: Fix that!

Any tags and annotations are added to copied tags/annotations, but specified requirements, and/or consequences will replace previous requirements/consequences, rather than being added to them.

Example

>>> g = DecisionGraph()
>>> g.addDecision('A')
0
>>> g.addUnexploredEdge('A', 'up')
1
>>> g.destination('A', 'up')
1
>>> g.destination('_u.0', 'return')
0
>>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
({}, {})
>>> g.destination('A', 'up')
1
>>> g.nameFor(1)
'B'
>>> g.destination('B', 'down')
0
>>> g.getDestination('B', 'return') is None
True
>>> '_u.0' in g.nameLookup
False
>>> g.getReciprocal('A', 'up')
'down'
>>> g.getReciprocal('B', 'down')
'up'
>>> # Two unexplored edges to the same node:
>>> g.addDecision('C')
2
>>> g.addTransition('B', 'next', 'C')
>>> g.addTransition('C', 'prev', 'B')
>>> g.setReciprocal('B', 'next', 'prev')
>>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
3
>>> g.addTransition('C', 'down', 'D')
>>> g.addTransition('D', 'up', 'C')
>>> g.setReciprocal('C', 'down', 'up')
>>> g.replaceUnconfirmed('C', 'down')
({}, {})
>>> g.destination('C', 'down')
3
>>> g.destination('A', 'next')
3
>>> g.destinationsFrom('D')
{'prev': 0, 'up': 2}
>>> g.decisionTags('D')
{}
>>> # An unexplored transition which turns out to connect to a
>>> # known decision, with name collisions
>>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
4
>>> g.tagDecision('_u.2', 'wet')
>>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
5
>>> g.tagDecision('_u.3', 'dry')
>>> # Add transitions that will collide when merged
>>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
6
>>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
7
>>> g.getReciprocal('A', 'prev')
'next'
>>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
({'prev': 'prev.1'}, {'up': 'up.1'})
>>> g.destination('A', 'prev')
3
>>> g.destination('D', 'next')
0
>>> g.getReciprocal('A', 'prev')
'next'
>>> g.getReciprocal('D', 'next')
'prev'
>>> # Note that further unexplored structures are NOT merged
>>> # even if they match against existing structures...
>>> g.destination('A', 'up.1')
6
>>> g.destination('D', 'prev.1')
7
>>> '_u.2' in g.nameLookup
False
>>> '_u.3' in g.nameLookup
False
>>> g.decisionTags('D') # tags are merged
{'dry': 1}
>>> g.decisionTags('A')
{'wet': 1}
>>> # Auto-renaming an anonymous unexplored node
>>> g.addUnexploredEdge('B', 'out')
8
>>> g.replaceUnconfirmed('B', 'out')
({}, {})
>>> '_u.6' in g
False
>>> g.destination('B', 'out')
8
>>> g.nameFor(8)
'_x.6'
>>> g.destination('_x.6', 'return')
1
>>> # Placing a node into a zone
>>> g.addUnexploredEdge('B', 'through')
9
>>> g.getDecision('E') is None
True
>>> g.replaceUnconfirmed(
...     'B',
...     'through',
...     'E',
...     'back',
...     placeInZone='Zone'
... )
({}, {})
>>> g.getDecision('E')
9
>>> g.destination('B', 'through')
9
>>> g.destination('E', 'back')
1
>>> g.zoneParents(9)
{'Zone'}
>>> g.addUnexploredEdge('E', 'farther')
10
>>> g.replaceUnconfirmed(
...     'E',
...     'farther',
...     'F',
...     'closer',
...     placeInZone=base.DefaultZone
... )
({}, {})
>>> g.destination('E', 'farther')
10
>>> g.destination('F', 'closer')
9
>>> g.zoneParents(10)
{'Zone'}
>>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
11
>>> g.replaceUnconfirmed(
...     'F',
...     'backwards',
...     'G',
...     'forwards',
...     placeInZone=base.DefaultZone
... )
({}, {})
>>> g.destination('F', 'backwards')
11
>>> g.destination('G', 'forwards')
10
>>> g.zoneParents(11)  # not changed since it already had a zone
{'Enoz'}
>>> # TODO: forceNew example
def endingID(self, name: str) -> int:
6352    def endingID(self, name: base.DecisionName) -> base.DecisionID:
6353        """
6354        Returns the decision ID for the ending with the specified name.
6355        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
6356        don't normally include any zone information. If no ending with
6357        the specified name already existed, then a new ending with that
6358        name will be created and its Decision ID will be returned.
6359
6360        If a new decision is created, it will be tagged as unconfirmed.
6361
6362        Note that endings mostly aren't special: they're normal
6363        decisions in a separate singular-focalized domain. However, some
6364        parts of the exploration and journal machinery treat them
6365        differently (in particular, taking certain actions via
6366        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
6367        active is an error.
6368        """
6369        # Create our new ending decision if we need to
6370        try:
6371            endID = self.resolveDecision(
6372                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
6373            )
6374        except MissingDecisionError:
6375            # Create a new decision for the ending
6376            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
6377            # Tag it as unconfirmed
6378            self.tagDecision(endID, 'unconfirmed')
6379
6380        return endID

Returns the decision ID for the ending with the specified name. Endings are disconnected decisions in the ENDINGS_DOMAIN; they don't normally include any zone information. If no ending with the specified name already existed, then a new ending with that name will be created and its Decision ID will be returned.

If a new decision is created, it will be tagged as unconfirmed.

Note that endings mostly aren't special: they're normal decisions in a separate singular-focalized domain. However, some parts of the exploration and journal machinery treat them differently (in particular, taking certain actions via advanceSituation while any decision in the ENDINGS_DOMAIN is active is an error.

def triggerGroupID(self, name: str) -> int:
6382    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
6383        """
6384        Given the name of a trigger group, returns the ID of the special
6385        node representing that trigger group in the `TRIGGERS_DOMAIN`.
6386        If the specified group didn't already exist, it will be created.
6387
6388        Trigger group decisions are not special: they just exist in a
6389        separate spreading-focalized domain and have a few API methods to
6390        access them, but all the normal decision-related API methods
6391        still work. Their intended use is for sets of global triggers,
6392        by attaching actions with the 'trigger' tag to them and then
6393        activating or deactivating them as needed.
6394        """
6395        result = self.getDecision(
6396            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
6397        )
6398        if result is None:
6399            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
6400        else:
6401            return result

Given the name of a trigger group, returns the ID of the special node representing that trigger group in the TRIGGERS_DOMAIN. If the specified group didn't already exist, it will be created.

Trigger group decisions are not special: they just exist in a separate spreading-focalized domain and have a few API methods to access them, but all the normal decision-related API methods still work. Their intended use is for sets of global triggers, by attaching actions with the 'trigger' tag to them and then activating or deactivating them as needed.

@staticmethod
def example(which: Literal['simple', 'abc']) -> DecisionGraph:
6403    @staticmethod
6404    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
6405        """
6406        Returns one of a number of example decision graphs, depending on
6407        the string given. It returns a fresh copy each time. The graphs
6408        are:
6409
6410        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
6411            and 2, each connected to the next in the sequence by a
6412            'next' transition with reciprocal 'prev'. In other words, a
6413            simple little triangle. There are no tags, annotations,
6414            requirements, consequences, mechanisms, or equivalences.
6415        - 'abc': A more complicated 3-node setup that introduces a
6416            little bit of everything. In this graph, we have the same
6417            three nodes, but different transitions:
6418
6419                * From A you can go 'left' to B with reciprocal 'right'.
6420                * From A you can also go 'up_left' to B with reciprocal
6421                    'up_right'. These transitions both require the
6422                    'grate' mechanism (which is at decision A) to be in
6423                    state 'open'.
6424                * From A you can go 'down' to C with reciprocal 'up'.
6425
6426            (In this graph, B and C are not directly connected to each
6427            other.)
6428
6429            The graph has two level-0 zones 'zoneA' and 'zoneB', along
6430            with a level-1 zone 'upZone'. Decisions A and C are in
6431            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
6432            not.
6433
6434            The decision A has annotation:
6435
6436                'This is a multi-word "annotation."'
6437
6438            The transition 'down' from A has annotation:
6439
6440                "Transition 'annotation.'"
6441
6442            Decision B has tags 'b' with value 1 and 'tag2' with value
6443            '"value"'.
6444
6445            Decision C has tag 'aw"ful' with value "ha'ha'".
6446
6447            Transition 'up' from C has tag 'fast' with value 1.
6448
6449            At decision C there are actions 'grab_helmet' and
6450            'pull_lever'.
6451
6452            The 'grab_helmet' transition requires that you don't have
6453            the 'helmet' capability, and gives you that capability,
6454            deactivating with delay 3.
6455
6456            The 'pull_lever' transition requires that you do have the
6457            'helmet' capability, and takes away that capability, but it
6458            also gives you 1 token, and if you have 2 tokens (before
6459            getting the one extra), it sets the 'grate' mechanism (which
6460            is a decision A) to state 'open' and deactivates.
6461
6462            The graph has an equivalence: having the 'helmet' capability
6463            satisfies requirements for the 'grate' mechanism to be in the
6464            'open' state.
6465
6466        """
6467        result = DecisionGraph()
6468        if which == 'simple':
6469            result.addDecision('A')  # id 0
6470            result.addDecision('B')  # id 1
6471            result.addDecision('C')  # id 2
6472            result.addTransition('A', 'next', 'B', 'prev')
6473            result.addTransition('B', 'next', 'C', 'prev')
6474            result.addTransition('C', 'next', 'A', 'prev')
6475        elif which == 'abc':
6476            result.addDecision('A')  # id 0
6477            result.addDecision('B')  # id 1
6478            result.addDecision('C')  # id 2
6479            result.createZone('zoneA', 0)
6480            result.createZone('zoneB', 0)
6481            result.createZone('upZone', 1)
6482            result.addZoneToZone('zoneA', 'upZone')
6483            result.addDecisionToZone('A', 'zoneA')
6484            result.addDecisionToZone('B', 'zoneB')
6485            result.addDecisionToZone('C', 'zoneA')
6486            result.addTransition('A', 'left', 'B', 'right')
6487            result.addTransition('A', 'up_left', 'B', 'up_right')
6488            result.addTransition('A', 'down', 'C', 'up')
6489            result.setTransitionRequirement(
6490                'A',
6491                'up_left',
6492                base.ReqMechanism('grate', 'open')
6493            )
6494            result.setTransitionRequirement(
6495                'B',
6496                'up_right',
6497                base.ReqMechanism('grate', 'open')
6498            )
6499            result.annotateDecision('A', 'This is a multi-word "annotation."')
6500            result.annotateTransition('A', 'down', "Transition 'annotation.'")
6501            result.tagDecision('B', 'b')
6502            result.tagDecision('B', 'tag2', '"value"')
6503            result.tagDecision('C', 'aw"ful', "ha'ha")
6504            result.tagTransition('C', 'up', 'fast')
6505            result.addMechanism('grate', 'A')
6506            result.addAction(
6507                'C',
6508                'grab_helmet',
6509                base.ReqNot(base.ReqCapability('helmet')),
6510                [
6511                    base.effect(gain='helmet'),
6512                    base.effect(deactivate=True, delay=3)
6513                ]
6514            )
6515            result.addAction(
6516                'C',
6517                'pull_lever',
6518                base.ReqCapability('helmet'),
6519                [
6520                    base.effect(lose='helmet'),
6521                    base.effect(gain=('token', 1)),
6522                    base.condition(
6523                        base.ReqTokens('token', 2),
6524                        [
6525                            base.effect(set=('grate', 'open')),
6526                            base.effect(deactivate=True)
6527                        ]
6528                    )
6529                ]
6530            )
6531            result.addEquivalence(
6532                base.ReqCapability('helmet'),
6533                (0, 'open')
6534            )
6535        else:
6536            raise ValueError(f"Invalid example name: {which!r}")
6537
6538        return result

Returns one of a number of example decision graphs, depending on the string given. It returns a fresh copy each time. The graphs are:

  • 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1, and 2, each connected to the next in the sequence by a 'next' transition with reciprocal 'prev'. In other words, a simple little triangle. There are no tags, annotations, requirements, consequences, mechanisms, or equivalences.
  • 'abc': A more complicated 3-node setup that introduces a little bit of everything. In this graph, we have the same three nodes, but different transitions:

    * From A you can go 'left' to B with reciprocal 'right'.
    * From A you can also go 'up_left' to B with reciprocal
        'up_right'. These transitions both require the
        'grate' mechanism (which is at decision A) to be in
        state 'open'.
    * From A you can go 'down' to C with reciprocal 'up'.
    

    (In this graph, B and C are not directly connected to each other.)

    The graph has two level-0 zones 'zoneA' and 'zoneB', along with a level-1 zone 'upZone'. Decisions A and C are in zoneA while B is in zoneB; zoneA is in upZone, but zoneB is not.

    The decision A has annotation:

    'This is a multi-word "annotation."'
    

    The transition 'down' from A has annotation:

    "Transition 'annotation.'"
    

    Decision B has tags 'b' with value 1 and 'tag2' with value '"value"'.

    Decision C has tag 'aw"ful' with value "ha'ha'".

    Transition 'up' from C has tag 'fast' with value 1.

    At decision C there are actions 'grab_helmet' and 'pull_lever'.

    The 'grab_helmet' transition requires that you don't have the 'helmet' capability, and gives you that capability, deactivating with delay 3.

    The 'pull_lever' transition requires that you do have the 'helmet' capability, and takes away that capability, but it also gives you 1 token, and if you have 2 tokens (before getting the one extra), it sets the 'grate' mechanism (which is a decision A) to state 'open' and deactivates.

    The graph has an equivalence: having the 'helmet' capability satisfies requirements for the 'grate' mechanism to be in the 'open' state.

Inherited Members
exploration.graphs.UniqueExitsGraph
new_edge_key
add_node
add_nodes_from
remove_node
remove_nodes_from
add_edge
add_edges_from
remove_edge
remove_edges_from
clear
clear_edges
reverse
removeEdgeByKey
removeEdgesByKey
connections
allEdgesTo
allEdges
textMapObj
networkx.classes.multidigraph.MultiDiGraph
edge_key_dict_factory
adj
succ
pred
edges
out_edges
in_edges
degree
in_degree
out_degree
is_multigraph
is_directed
to_undirected
networkx.classes.multigraph.MultiGraph
to_directed_class
to_undirected_class
has_edge
get_edge_data
copy
to_directed
number_of_edges
networkx.classes.digraph.DiGraph
graph
has_successor
has_predecessor
successors
neighbors
predecessors
networkx.classes.graph.Graph
node_dict_factory
node_attr_dict_factory
adjlist_outer_dict_factory
adjlist_inner_dict_factory
edge_attr_dict_factory
graph_attr_dict_factory
name
nodes
number_of_nodes
order
has_node
add_weighted_edges_from
update
adjacency
subgraph
edge_subgraph
size
nbunch_iter
def emptySituation() -> exploration.base.Situation:
6545def emptySituation() -> base.Situation:
6546    """
6547    Creates and returns an empty situation: A situation that has an
6548    empty `DecisionGraph`, an empty `State`, a 'pending' decision type
6549    with `None` as the action taken, no tags, and no annotations.
6550    """
6551    return base.Situation(
6552        graph=DecisionGraph(),
6553        state=base.emptyState(),
6554        type='pending',
6555        action=None,
6556        saves={},
6557        tags={},
6558        annotations=[]
6559    )

Creates and returns an empty situation: A situation that has an empty DecisionGraph, an empty State, a 'pending' decision type with None as the action taken, no tags, and no annotations.

class DiscreteExploration:
 6562class DiscreteExploration:
 6563    """
 6564    A list of `Situations` each of which contains a `DecisionGraph`
 6565    representing exploration over time, with `States` containing
 6566    `FocalContext` information for each step and 'taken' values for the
 6567    transition selected (at a particular decision) in that step. Each
 6568    decision graph represents a new state of the world (and/or new
 6569    knowledge about a persisting state of the world), and the 'taken'
 6570    transition in one situation transition indicates which option was
 6571    selected, or what event happened to cause update(s). Depending on the
 6572    resolution, it could represent a close record of every decision made
 6573    or a more coarse set of snapshots from gameplay with more time in
 6574    between.
 6575
 6576    The steps of the exploration can also be tagged and annotated (see
 6577    `tagStep` and `annotateStep`).
 6578
 6579    It also holds a `layouts` field that includes zero or more
 6580    `base.Layout`s by name.
 6581
 6582    When a new `DiscreteExploration` is created, it starts out with an
 6583    empty `Situation` that contains an empty `DecisionGraph`. Use the
 6584    `start` method to name the starting decision point and set things up
 6585    for other methods.
 6586
 6587    Tracking of player goals and destinations is also planned (see the
 6588    `quest`, `progress`, `complete`, `destination`, and `arrive` methods).
 6589    TODO: That
 6590    """
 6591    def __init__(self) -> None:
 6592        self.situations: List[base.Situation] = [
 6593            base.Situation(
 6594                graph=DecisionGraph(),
 6595                state=base.emptyState(),
 6596                type='pending',
 6597                action=None,
 6598                saves={},
 6599                tags={},
 6600                annotations=[]
 6601            )
 6602        ]
 6603        self.layouts: Dict[str, base.Layout] = {}
 6604
 6605    # Note: not hashable
 6606
 6607    def __eq__(self, other):
 6608        """
 6609        Equality checker. `DiscreteExploration`s can only be equal to
 6610        other `DiscreteExploration`s, not to other kinds of things.
 6611        """
 6612        if not isinstance(other, DiscreteExploration):
 6613            return False
 6614        else:
 6615            return self.situations == other.situations
 6616
 6617    @staticmethod
 6618    def fromGraph(
 6619        graph: DecisionGraph,
 6620        state: Optional[base.State] = None
 6621    ) -> 'DiscreteExploration':
 6622        """
 6623        Creates an exploration which has just a single step whose graph
 6624        is the entire specified graph, with the specified decision as
 6625        the primary decision (if any). The graph is copied, so that
 6626        changes to the exploration will not modify it. A starting state
 6627        may also be specified if desired, although if not an empty state
 6628        will be used (a provided starting state is NOT copied, but used
 6629        directly).
 6630
 6631        Example:
 6632
 6633        >>> g = DecisionGraph()
 6634        >>> g.addDecision('Room1')
 6635        0
 6636        >>> g.addDecision('Room2')
 6637        1
 6638        >>> g.addTransition('Room1', 'door', 'Room2', 'door')
 6639        >>> e = DiscreteExploration.fromGraph(g)
 6640        >>> len(e)
 6641        1
 6642        >>> e.getSituation().graph == g
 6643        True
 6644        >>> e.getActiveDecisions()
 6645        set()
 6646        >>> e.primaryDecision() is None
 6647        True
 6648        >>> e.observe('Room1', 'hatch')
 6649        2
 6650        >>> e.getSituation().graph == g
 6651        False
 6652        >>> e.getSituation().graph.destinationsFrom('Room1')
 6653        {'door': 1, 'hatch': 2}
 6654        >>> g.destinationsFrom('Room1')
 6655        {'door': 1}
 6656        """
 6657        result = DiscreteExploration()
 6658        result.situations[0] = base.Situation(
 6659            graph=copy.deepcopy(graph),
 6660            state=base.emptyState() if state is None else state,
 6661            type='pending',
 6662            action=None,
 6663            saves={},
 6664            tags={},
 6665            annotations=[]
 6666        )
 6667        return result
 6668
 6669    def __len__(self) -> int:
 6670        """
 6671        The 'length' of an exploration is the number of steps.
 6672        """
 6673        return len(self.situations)
 6674
 6675    def __getitem__(self, i: int) -> base.Situation:
 6676        """
 6677        Indexing an exploration returns the situation at that step.
 6678        """
 6679        return self.situations[i]
 6680
 6681    def __iter__(self) -> Iterator[base.Situation]:
 6682        """
 6683        Iterating over an exploration yields each `Situation` in order.
 6684        """
 6685        for i in range(len(self)):
 6686            yield self[i]
 6687
 6688    def getSituation(self, step: int = -1) -> base.Situation:
 6689        """
 6690        Returns a `base.Situation` named tuple detailing the state of
 6691        the exploration at a given step (or at the current step if no
 6692        argument is given). Note that this method works the same
 6693        way as indexing the exploration: see `__getitem__`.
 6694
 6695        Raises an `IndexError` if asked for a step that's out-of-range.
 6696        """
 6697        return self[step]
 6698
 6699    def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]:
 6700        """
 6701        Returns the current primary `base.DecisionID`, or the primary
 6702        decision from a specific step if one is specified. This may be
 6703        `None` for some steps, but mostly it's the destination of the
 6704        transition taken in the previous step.
 6705        """
 6706        return self[step].state['primaryDecision']
 6707
 6708    def effectiveCapabilities(
 6709        self,
 6710        step: int = -1
 6711    ) -> base.CapabilitySet:
 6712        """
 6713        Returns the effective capability set for the specified step
 6714        (default is the last/current step). See
 6715        `base.effectiveCapabilities`.
 6716        """
 6717        return base.effectiveCapabilitySet(self.getSituation(step).state)
 6718
 6719    def getCommonContext(
 6720        self,
 6721        step: Optional[int] = None
 6722    ) -> base.FocalContext:
 6723        """
 6724        Returns the common `FocalContext` at the specified step, or at
 6725        the current step if no argument is given. Raises an `IndexError`
 6726        if an invalid step is specified.
 6727        """
 6728        if step is None:
 6729            step = -1
 6730        state = self.getSituation(step).state
 6731        return state['common']
 6732
 6733    def getActiveContext(
 6734        self,
 6735        step: Optional[int] = None
 6736    ) -> base.FocalContext:
 6737        """
 6738        Returns the active `FocalContext` at the specified step, or at
 6739        the current step if no argument is provided. Raises an
 6740        `IndexError` if an invalid step is specified.
 6741        """
 6742        if step is None:
 6743            step = -1
 6744        state = self.getSituation(step).state
 6745        return state['contexts'][state['activeContext']]
 6746
 6747    def addFocalContext(self, name: base.FocalContextName) -> None:
 6748        """
 6749        Adds a new empty focal context to our set of focal contexts (see
 6750        `emptyFocalContext`). Use `setActiveContext` to swap to it.
 6751        Raises a `FocalContextCollisionError` if the name is already in
 6752        use.
 6753        """
 6754        contextMap = self.getSituation().state['contexts']
 6755        if name in contextMap:
 6756            raise FocalContextCollisionError(
 6757                f"Cannot add focal context {name!r}: a focal context"
 6758                f" with that name already exists."
 6759            )
 6760        contextMap[name] = base.emptyFocalContext()
 6761
 6762    def setActiveContext(self, which: base.FocalContextName) -> None:
 6763        """
 6764        Sets the active context to the named focal context, creating it
 6765        if it did not already exist (makes changes to the current
 6766        situation only). Does not add an exploration step (use
 6767        `advanceSituation` with a 'swap' action for that).
 6768        """
 6769        state = self.getSituation().state
 6770        contextMap = state['contexts']
 6771        if which not in contextMap:
 6772            self.addFocalContext(which)
 6773        state['activeContext'] = which
 6774
 6775    def createDomain(
 6776        self,
 6777        name: base.Domain,
 6778        focalization: base.DomainFocalization = 'singular',
 6779        makeActive: bool = False,
 6780        inCommon: Union[bool, Literal["both"]] = "both"
 6781    ) -> None:
 6782        """
 6783        Creates a new domain with the given focalization type, in either
 6784        the common context (`inCommon` = `True`) the active context
 6785        (`inCommon` = `False`) or both (the default; `inCommon` = 'both').
 6786        The domain's focalization will be set to the given
 6787        `focalization` value (default 'singular') and it will have no
 6788        active decisions. Raises a `DomainCollisionError` if a domain
 6789        with the specified name already exists.
 6790
 6791        Creates the domain in the current situation.
 6792
 6793        If `makeActive` is set to `True` (default is `False`) then the
 6794        domain will be made active in whichever context(s) it's created
 6795        in.
 6796        """
 6797        now = self.getSituation()
 6798        state = now.state
 6799        modify = []
 6800        if inCommon in (True, "both"):
 6801            modify.append(('common', state['common']))
 6802        if inCommon in (False, "both"):
 6803            acName = state['activeContext']
 6804            modify.append(
 6805                ('current ({repr(acName)})', state['contexts'][acName])
 6806            )
 6807
 6808        for (fcType, fc) in modify:
 6809            if name in fc['focalization']:
 6810                raise DomainCollisionError(
 6811                    f"Cannot create domain {repr(name)} because a"
 6812                    f" domain with that name already exists in the"
 6813                    f" {fcType} focal context."
 6814                )
 6815            fc['focalization'][name] = focalization
 6816            if makeActive:
 6817                fc['activeDomains'].add(name)
 6818            if focalization == "spreading":
 6819                fc['activeDecisions'][name] = set()
 6820            elif focalization == "plural":
 6821                fc['activeDecisions'][name] = {}
 6822            else:
 6823                fc['activeDecisions'][name] = None
 6824
 6825    def activateDomain(
 6826        self,
 6827        domain: base.Domain,
 6828        activate: bool = True,
 6829        inContext: base.ContextSpecifier = "active"
 6830    ) -> None:
 6831        """
 6832        Sets the given domain as active (or inactive if 'activate' is
 6833        given as `False`) in the specified context (default "active").
 6834
 6835        Modifies the current situation.
 6836        """
 6837        fc: base.FocalContext
 6838        if inContext == "active":
 6839            fc = self.getActiveContext()
 6840        elif inContext == "common":
 6841            fc = self.getCommonContext()
 6842
 6843        if activate:
 6844            fc['activeDomains'].add(domain)
 6845        else:
 6846            try:
 6847                fc['activeDomains'].remove(domain)
 6848            except KeyError:
 6849                pass
 6850
 6851    def createTriggerGroup(
 6852        self,
 6853        name: base.DecisionName
 6854    ) -> base.DecisionID:
 6855        """
 6856        Creates a new trigger group with the given name, returning the
 6857        decision ID for that trigger group. If this is the first trigger
 6858        group being created, also creates the `TRIGGERS_DOMAIN` domain
 6859        as a spreading-focalized domain that's active in the common
 6860        context (but does NOT set the created trigger group as an active
 6861        decision in that domain).
 6862
 6863        You can use 'goto' effects to activate trigger domains via
 6864        consequences, and 'retreat' effects to deactivate them.
 6865
 6866        Creating a second trigger group with the same name as another
 6867        results in a `ValueError`.
 6868
 6869        TODO: Retreat effects
 6870        """
 6871        ctx = self.getCommonContext()
 6872        if TRIGGERS_DOMAIN not in ctx['focalization']:
 6873            self.createDomain(
 6874                TRIGGERS_DOMAIN,
 6875                focalization='spreading',
 6876                makeActive=True,
 6877                inCommon=True
 6878            )
 6879
 6880        graph = self.getSituation().graph
 6881        if graph.getDecision(
 6882            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 6883        ) is not None:
 6884            raise ValueError(
 6885                f"Cannot create trigger group {name!r}: a trigger group"
 6886                f" with that name already exists."
 6887            )
 6888
 6889        return self.getSituation().graph.triggerGroupID(name)
 6890
 6891    def toggleTriggerGroup(
 6892        self,
 6893        name: base.DecisionName,
 6894        setActive: Union[bool, None] = None
 6895    ):
 6896        """
 6897        Toggles whether the specified trigger group (a decision in the
 6898        `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as
 6899        the `setActive` argument (instead of the default `None`) to set
 6900        the state directly instead of toggling it.
 6901
 6902        Note that trigger groups are decisions in a spreading-focalized
 6903        domain, so they can be activated or deactivated by the 'goto'
 6904        and 'retreat' effects as well.
 6905
 6906        This does not affect whether the `TRIGGERS_DOMAIN` itself is
 6907        active (normally it would always be active).
 6908
 6909        Raises a `MissingDecisionError` if the specified trigger group
 6910        does not exist yet, including when the entire `TRIGGERS_DOMAIN`
 6911        does not exist. Raises a `KeyError` if the target group exists
 6912        but the `TRIGGERS_DOMAIN` has not been set up properly.
 6913        """
 6914        ctx = self.getCommonContext()
 6915        tID = self.getSituation().graph.resolveDecision(
 6916            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 6917        )
 6918        activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN]
 6919        assert isinstance(activeGroups, set)
 6920        if tID in activeGroups:
 6921            if setActive is not True:
 6922                activeGroups.remove(tID)
 6923        else:
 6924            if setActive is not False:
 6925                activeGroups.add(tID)
 6926
 6927    def getActiveDecisions(
 6928        self,
 6929        step: Optional[int] = None,
 6930        inCommon: Union[bool, Literal["both"]] = "both"
 6931    ) -> Set[base.DecisionID]:
 6932        """
 6933        Returns the set of active decisions at the given step index, or
 6934        at the current step if no step is specified. Raises an
 6935        `IndexError` if the step index is out of bounds (see `__len__`).
 6936        May return an empty set if no decisions are active.
 6937
 6938        If `inCommon` is set to "both" (the default) then decisions
 6939        active in either the common or active context are returned. Set
 6940        it to `True` or `False` to return only decisions active in the
 6941        common (when `True`) or  active (when `False`) context.
 6942        """
 6943        if step is None:
 6944            step = -1
 6945        state = self.getSituation(step).state
 6946        if inCommon == "both":
 6947            return base.combinedDecisionSet(state)
 6948        elif inCommon is True:
 6949            return base.activeDecisionSet(state['common'])
 6950        elif inCommon is False:
 6951            return base.activeDecisionSet(
 6952                state['contexts'][state['activeContext']]
 6953            )
 6954        else:
 6955            raise ValueError(
 6956                f"Invalid inCommon value {repr(inCommon)} (must be"
 6957                f" 'both', True, or False)."
 6958            )
 6959
 6960    def setActiveDecisionsAtStep(
 6961        self,
 6962        step: int,
 6963        domain: base.Domain,
 6964        activate: Union[
 6965            base.DecisionID,
 6966            Dict[base.FocalPointName, Optional[base.DecisionID]],
 6967            Set[base.DecisionID]
 6968        ],
 6969        inCommon: bool = False
 6970    ) -> None:
 6971        """
 6972        Changes the activation status of decisions in the active
 6973        `FocalContext` at the specified step, for the specified domain
 6974        (see `currentActiveContext`). Does this without adding an
 6975        exploration step, which is unusual: normally you should use
 6976        another method like `warp` to update active decisions.
 6977
 6978        Note that this does not change which domains are active, and
 6979        setting active decisions in inactive domains does not make those
 6980        decisions active overall.
 6981
 6982        Which decisions to activate or deactivate are specified as
 6983        either a single `DecisionID`, a list of them, or a set of them,
 6984        depending on the `DomainFocalization` setting in the selected
 6985        `FocalContext` for the specified domain. A `TypeError` will be
 6986        raised if the wrong kind of decision information is provided. If
 6987        the focalization context does not have any focalization value for
 6988        the domain in question, it will be set based on the kind of
 6989        active decision information specified.
 6990
 6991        A `MissingDecisionError` will be raised if a decision is
 6992        included which is not part of the current `DecisionGraph`.
 6993        The provided information will overwrite the previous active
 6994        decision information.
 6995
 6996        If `inCommon` is set to `True`, then decisions are activated or
 6997        deactivated in the common context, instead of in the active
 6998        context.
 6999
 7000        Example:
 7001
 7002        >>> e = DiscreteExploration()
 7003        >>> e.getActiveDecisions()
 7004        set()
 7005        >>> graph = e.getSituation().graph
 7006        >>> graph.addDecision('A')
 7007        0
 7008        >>> graph.addDecision('B')
 7009        1
 7010        >>> graph.addDecision('C')
 7011        2
 7012        >>> e.setActiveDecisionsAtStep(0, 'main', 0)
 7013        >>> e.getActiveDecisions()
 7014        {0}
 7015        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 7016        >>> e.getActiveDecisions()
 7017        {1}
 7018        >>> graph = e.getSituation().graph
 7019        >>> graph.addDecision('One', domain='numbers')
 7020        3
 7021        >>> graph.addDecision('Two', domain='numbers')
 7022        4
 7023        >>> graph.addDecision('Three', domain='numbers')
 7024        5
 7025        >>> graph.addDecision('Bear', domain='animals')
 7026        6
 7027        >>> graph.addDecision('Spider', domain='animals')
 7028        7
 7029        >>> graph.addDecision('Eel', domain='animals')
 7030        8
 7031        >>> ac = e.getActiveContext()
 7032        >>> ac['focalization']['numbers'] = 'plural'
 7033        >>> ac['focalization']['animals'] = 'spreading'
 7034        >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
 7035        >>> ac['activeDecisions']['animals'] = set()
 7036        >>> cc = e.getCommonContext()
 7037        >>> cc['focalization']['numbers'] = 'plural'
 7038        >>> cc['focalization']['animals'] = 'spreading'
 7039        >>> cc['activeDecisions']['numbers'] = {'z': None}
 7040        >>> cc['activeDecisions']['animals'] = set()
 7041        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
 7042        >>> e.getActiveDecisions()
 7043        {1}
 7044        >>> e.activateDomain('numbers')
 7045        >>> e.getActiveDecisions()
 7046        {1, 3}
 7047        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
 7048        >>> e.getActiveDecisions()
 7049        {1, 4}
 7050        >>> # Wrong domain for the decision ID:
 7051        >>> e.setActiveDecisionsAtStep(0, 'main', 3)
 7052        Traceback (most recent call last):
 7053        ...
 7054        ValueError...
 7055        >>> # Wrong domain for one of the decision IDs:
 7056        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
 7057        Traceback (most recent call last):
 7058        ...
 7059        ValueError...
 7060        >>> # Wrong kind of decision information provided.
 7061        >>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
 7062        Traceback (most recent call last):
 7063        ...
 7064        TypeError...
 7065        >>> e.getActiveDecisions()
 7066        {1, 4}
 7067        >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
 7068        >>> e.getActiveDecisions()
 7069        {1, 4}
 7070        >>> e.activateDomain('animals')
 7071        >>> e.getActiveDecisions()
 7072        {1, 4, 6, 7}
 7073        >>> e.setActiveDecisionsAtStep(0, 'animals', {8})
 7074        >>> e.getActiveDecisions()
 7075        {8, 1, 4}
 7076        >>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
 7077        Traceback (most recent call last):
 7078        ...
 7079        IndexError...
 7080        >>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
 7081        Traceback (most recent call last):
 7082        ...
 7083        ValueError...
 7084
 7085        Example of active/common contexts:
 7086
 7087        >>> e = DiscreteExploration()
 7088        >>> graph = e.getSituation().graph
 7089        >>> graph.addDecision('A')
 7090        0
 7091        >>> graph.addDecision('B')
 7092        1
 7093        >>> e.activateDomain('main', inContext="common")
 7094        >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
 7095        >>> e.getActiveDecisions()
 7096        {0}
 7097        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 7098        >>> e.getActiveDecisions()
 7099        {0}
 7100        >>> # (Still active since it's active in the common context)
 7101        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 7102        >>> e.getActiveDecisions()
 7103        {0, 1}
 7104        >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
 7105        >>> e.getActiveDecisions()
 7106        {1}
 7107        >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
 7108        >>> e.getActiveDecisions()
 7109        {1}
 7110        >>> # (Still active since it's active in the active context)
 7111        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 7112        >>> e.getActiveDecisions()
 7113        set()
 7114        """
 7115        now = self.getSituation(step)
 7116        graph = now.graph
 7117        if inCommon:
 7118            context = self.getCommonContext(step)
 7119        else:
 7120            context = self.getActiveContext(step)
 7121
 7122        defaultFocalization: base.DomainFocalization = 'singular'
 7123        if isinstance(activate, base.DecisionID):
 7124            defaultFocalization = 'singular'
 7125        elif isinstance(activate, dict):
 7126            defaultFocalization = 'plural'
 7127        elif isinstance(activate, set):
 7128            defaultFocalization = 'spreading'
 7129        elif domain not in context['focalization']:
 7130            raise TypeError(
 7131                f"Domain {domain!r} has no focalization in the"
 7132                f" {'common' if inCommon else 'active'} context,"
 7133                f" and the specified position doesn't imply one."
 7134            )
 7135
 7136        focalization = base.getDomainFocalization(
 7137            context,
 7138            domain,
 7139            defaultFocalization
 7140        )
 7141
 7142        # Check domain & existence of decision(s) in question
 7143        if activate is None:
 7144            pass
 7145        elif isinstance(activate, base.DecisionID):
 7146            if activate not in graph:
 7147                raise MissingDecisionError(
 7148                    f"There is no decision {activate} at step {step}."
 7149                )
 7150            if graph.domainFor(activate) != domain:
 7151                raise ValueError(
 7152                    f"Can't set active decisions in domain {domain!r}"
 7153                    f" to decision {graph.identityOf(activate)} because"
 7154                    f" that decision is in actually in domain"
 7155                    f" {graph.domainFor(activate)!r}."
 7156                )
 7157        elif isinstance(activate, dict):
 7158            for fpName, pos in activate.items():
 7159                if pos is None:
 7160                    continue
 7161                if pos not in graph:
 7162                    raise MissingDecisionError(
 7163                        f"There is no decision {pos} at step {step}."
 7164                    )
 7165                if graph.domainFor(pos) != domain:
 7166                    raise ValueError(
 7167                        f"Can't set active decision for focal point"
 7168                        f" {fpName!r} in domain {domain!r}"
 7169                        f" to decision {graph.identityOf(pos)} because"
 7170                        f" that decision is in actually in domain"
 7171                        f" {graph.domainFor(pos)!r}."
 7172                    )
 7173        elif isinstance(activate, set):
 7174            for pos in activate:
 7175                if pos not in graph:
 7176                    raise MissingDecisionError(
 7177                        f"There is no decision {pos} at step {step}."
 7178                    )
 7179                if graph.domainFor(pos) != domain:
 7180                    raise ValueError(
 7181                        f"Can't set {graph.identityOf(pos)} as an"
 7182                        f" active decision in domain {domain!r} to"
 7183                        f" decision because that decision is in"
 7184                        f" actually in domain {graph.domainFor(pos)!r}."
 7185                    )
 7186        else:
 7187            raise TypeError(
 7188                f"Domain {domain!r} has no focalization in the"
 7189                f" {'common' if inCommon else 'active'} context,"
 7190                f" and the specified position doesn't imply one:"
 7191                f"\n{activate!r}"
 7192            )
 7193
 7194        if focalization == 'singular':
 7195            if activate is None or isinstance(activate, base.DecisionID):
 7196                if activate is not None:
 7197                    targetDomain = graph.domainFor(activate)
 7198                    if activate not in graph:
 7199                        raise MissingDecisionError(
 7200                            f"There is no decision {activate} in the"
 7201                            f" graph at step {step}."
 7202                        )
 7203                    elif targetDomain != domain:
 7204                        raise ValueError(
 7205                            f"At step {step}, decision {activate} cannot"
 7206                            f" be the active decision for domain"
 7207                            f" {repr(domain)} because it is in a"
 7208                            f" different domain ({repr(targetDomain)})."
 7209                        )
 7210                context['activeDecisions'][domain] = activate
 7211            else:
 7212                raise TypeError(
 7213                    f"{'Common' if inCommon else 'Active'} focal"
 7214                    f" context at step {step} has {repr(focalization)}"
 7215                    f" focalization for domain {repr(domain)}, so the"
 7216                    f" active decision must be a single decision or"
 7217                    f" None.\n(You provided: {repr(activate)})"
 7218                )
 7219        elif focalization == 'plural':
 7220            if (
 7221                isinstance(activate, dict)
 7222            and all(
 7223                    isinstance(k, base.FocalPointName)
 7224                    for k in activate.keys()
 7225                )
 7226            and all(
 7227                    v is None or isinstance(v, base.DecisionID)
 7228                    for v in activate.values()
 7229                )
 7230            ):
 7231                for v in activate.values():
 7232                    if v is not None:
 7233                        targetDomain = graph.domainFor(v)
 7234                        if v not in graph:
 7235                            raise MissingDecisionError(
 7236                                f"There is no decision {v} in the graph"
 7237                                f" at step {step}."
 7238                            )
 7239                        elif targetDomain != domain:
 7240                            raise ValueError(
 7241                                f"At step {step}, decision {activate}"
 7242                                f" cannot be an active decision for"
 7243                                f" domain {repr(domain)} because it is"
 7244                                f" in a different domain"
 7245                                f" ({repr(targetDomain)})."
 7246                            )
 7247                context['activeDecisions'][domain] = activate
 7248            else:
 7249                raise TypeError(
 7250                    f"{'Common' if inCommon else 'Active'} focal"
 7251                    f" context at step {step} has {repr(focalization)}"
 7252                    f" focalization for domain {repr(domain)}, so the"
 7253                    f" active decision must be a dictionary mapping"
 7254                    f" focal point names to decision IDs (or Nones)."
 7255                    f"\n(You provided: {repr(activate)})"
 7256                )
 7257        elif focalization == 'spreading':
 7258            if (
 7259                isinstance(activate, set)
 7260            and all(isinstance(x, base.DecisionID) for x in activate)
 7261            ):
 7262                for x in activate:
 7263                    targetDomain = graph.domainFor(x)
 7264                    if x not in graph:
 7265                        raise MissingDecisionError(
 7266                            f"There is no decision {x} in the graph"
 7267                            f" at step {step}."
 7268                        )
 7269                    elif targetDomain != domain:
 7270                        raise ValueError(
 7271                            f"At step {step}, decision {activate}"
 7272                            f" cannot be an active decision for"
 7273                            f" domain {repr(domain)} because it is"
 7274                            f" in a different domain"
 7275                            f" ({repr(targetDomain)})."
 7276                        )
 7277                context['activeDecisions'][domain] = activate
 7278            else:
 7279                raise TypeError(
 7280                    f"{'Common' if inCommon else 'Active'} focal"
 7281                    f" context at step {step} has {repr(focalization)}"
 7282                    f" focalization for domain {repr(domain)}, so the"
 7283                    f" active decision must be a set of decision IDs"
 7284                    f"\n(You provided: {repr(activate)})"
 7285                )
 7286        else:
 7287            raise RuntimeError(
 7288                f"Invalid focalization value {repr(focalization)} for"
 7289                f" domain {repr(domain)} at step {step}."
 7290            )
 7291
 7292    def movementAtStep(self, step: int = -1) -> Tuple[
 7293        Union[base.DecisionID, Set[base.DecisionID], None],
 7294        Optional[base.Transition],
 7295        Union[base.DecisionID, Set[base.DecisionID], None]
 7296    ]:
 7297        """
 7298        Given a step number, returns information about the starting
 7299        decision, transition taken, and destination decision for that
 7300        step. Not all steps have all of those, so some items may be
 7301        `None`.
 7302
 7303        For steps where there is no action, where a decision is still
 7304        pending, or where the action type is 'focus', 'swap', 'focalize',
 7305        or 'revertTo', the result will be `(None, None, None)`, unless a
 7306        primary decision is available in which case the first item in the
 7307        tuple will be that decision. For 'start' actions, the starting
 7308        position and transition will be `None` (again unless the step had
 7309        a primary decision) but the destination will be the ID of the
 7310        node started at. For 'revertTo' actions, the destination will be
 7311        the primary decision of the state reverted to, if available.
 7312
 7313        Also, if the action taken has multiple potential or actual start
 7314        or end points, these may be sets of decision IDs instead of
 7315        single IDs.
 7316
 7317        Note that the primary decision of the starting state is usually
 7318        used as the from-decision, but in some cases an action dictates
 7319        taking a transition from a different decision, and this function
 7320        will return that decision as the from-decision.
 7321
 7322        TODO: Examples!
 7323
 7324        TODO: Account for bounce/follow/goto effects!!!
 7325        """
 7326        now = self.getSituation(step)
 7327        action = now.action
 7328        graph = now.graph
 7329        primary = now.state['primaryDecision']
 7330
 7331        if action is None:
 7332            return (primary, None, None)
 7333
 7334        aType = action[0]
 7335        fromID: Optional[base.DecisionID]
 7336        destID: Optional[base.DecisionID]
 7337        transition: base.Transition
 7338        outcomes: List[bool]
 7339
 7340        if aType in ('noAction', 'focus', 'swap', 'focalize'):
 7341            return (primary, None, None)
 7342        elif aType == 'start':
 7343            assert len(action) == 7
 7344            where = cast(
 7345                Union[
 7346                    base.DecisionID,
 7347                    Dict[base.FocalPointName, base.DecisionID],
 7348                    Set[base.DecisionID]
 7349                ],
 7350                action[1]
 7351            )
 7352            if isinstance(where, dict):
 7353                where = set(where.values())
 7354            return (primary, None, where)
 7355        elif aType in ('take', 'explore'):
 7356            if (
 7357                (len(action) == 4 or len(action) == 7)
 7358            and isinstance(action[2], base.DecisionID)
 7359            ):
 7360                fromID = action[2]
 7361                assert isinstance(action[3], tuple)
 7362                transition, outcomes = action[3]
 7363                if (
 7364                    action[0] == "explore"
 7365                and isinstance(action[4], base.DecisionID)
 7366                ):
 7367                    destID = action[4]
 7368                else:
 7369                    destID = graph.getDestination(fromID, transition)
 7370                return (fromID, transition, destID)
 7371            elif (
 7372                (len(action) == 3 or len(action) == 6)
 7373            and isinstance(action[1], tuple)
 7374            and isinstance(action[2], base.Transition)
 7375            and len(action[1]) == 3
 7376            and action[1][0] in get_args(base.ContextSpecifier)
 7377            and isinstance(action[1][1], base.Domain)
 7378            and isinstance(action[1][2], base.FocalPointName)
 7379            ):
 7380                fromID = base.resolvePosition(now, action[1])
 7381                if fromID is None:
 7382                    raise InvalidActionError(
 7383                        f"{aType!r} action at step {step} has position"
 7384                        f" {action[1]!r} which cannot be resolved to a"
 7385                        f" decision."
 7386                    )
 7387                transition, outcomes = action[2]
 7388                if (
 7389                    action[0] == "explore"
 7390                and isinstance(action[3], base.DecisionID)
 7391                ):
 7392                    destID = action[3]
 7393                else:
 7394                    destID = graph.getDestination(fromID, transition)
 7395                return (fromID, transition, destID)
 7396            else:
 7397                raise InvalidActionError(
 7398                    f"Malformed {aType!r} action:\n{repr(action)}"
 7399                )
 7400        elif aType == 'warp':
 7401            if len(action) != 3:
 7402                raise InvalidActionError(
 7403                    f"Malformed 'warp' action:\n{repr(action)}"
 7404                )
 7405            dest = action[2]
 7406            assert isinstance(dest, base.DecisionID)
 7407            if action[1] in get_args(base.ContextSpecifier):
 7408                # Unspecified starting point; find active decisions in
 7409                # same domain if primary is None
 7410                if primary is not None:
 7411                    return (primary, None, dest)
 7412                else:
 7413                    toDomain = now.graph.domainFor(dest)
 7414                    # TODO: Could check destination focalization here...
 7415                    active = self.getActiveDecisions(step)
 7416                    sameDomain = set(
 7417                        dID
 7418                        for dID in active
 7419                        if now.graph.domainFor(dID) == toDomain
 7420                    )
 7421                    if len(sameDomain) == 1:
 7422                        return (
 7423                            list(sameDomain)[0],
 7424                            None,
 7425                            dest
 7426                        )
 7427                    else:
 7428                        return (
 7429                            sameDomain,
 7430                            None,
 7431                            dest
 7432                        )
 7433            else:
 7434                if (
 7435                    not isinstance(action[1], tuple)
 7436                or not len(action[1]) == 3
 7437                or not action[1][0] in get_args(base.ContextSpecifier)
 7438                or not isinstance(action[1][1], base.Domain)
 7439                or not isinstance(action[1][2], base.FocalPointName)
 7440                ):
 7441                    raise InvalidActionError(
 7442                        f"Malformed 'warp' action:\n{repr(action)}"
 7443                    )
 7444                return (
 7445                    base.resolvePosition(now, action[1]),
 7446                    None,
 7447                    dest
 7448                )
 7449        elif aType == 'revertTo':
 7450            assert len(action) == 3  # type, save slot, & aspects
 7451            if primary is not None:
 7452                cameFrom = primary
 7453            nextSituation = self.getSituation(step + 1)
 7454            wentTo = nextSituation.state['primaryDecision']
 7455            return (primary, None, wentTo)
 7456        else:
 7457            raise InvalidActionError(
 7458                f"Action taken had invalid action type {repr(aType)}:"
 7459                f"\n{repr(action)}"
 7460            )
 7461
 7462    def latestStepWithDecision(
 7463        self,
 7464        dID: base.DecisionID,
 7465        startFrom: int = -1
 7466    ) -> int:
 7467        """
 7468        Scans backwards through exploration steps until it finds a graph
 7469        that contains a decision with the specified ID, and returns the
 7470        step number of that step. Instead of starting from the last step,
 7471        you can tell it to start from a different step (either positive
 7472        or negative index) via `startFrom`. Raises a
 7473        `MissingDecisionError` if there is no such step.
 7474        """
 7475        if startFrom < 0:
 7476            startFrom = len(self) + startFrom
 7477        for step in range(startFrom, -1, -1):
 7478            graph = self.getSituation(step).graph
 7479            try:
 7480                return step
 7481            except MissingDecisionError:
 7482                continue
 7483        raise MissingDecisionError(
 7484            f"Decision {dID!r} does not exist at any step of the"
 7485            f" exploration."
 7486        )
 7487
 7488    def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
 7489        """
 7490        Looks up decision info for the given decision in the latest step
 7491        in which that decision exists (which will usually be the final
 7492        exploration step, unless the decision was merged or otherwise
 7493        removed along the way). This will raise a `MissingDecisionError`
 7494        only if there is no step at which the specified decision exists.
 7495        """
 7496        for step in range(len(self) - 1, -1, -1):
 7497            graph = self.getSituation(step).graph
 7498            try:
 7499                return graph.decisionInfo(dID)
 7500            except MissingDecisionError:
 7501                continue
 7502        raise MissingDecisionError(
 7503            f"Decision {dID!r} does not exist at any step of the"
 7504            f" exploration."
 7505        )
 7506
 7507    def latestTransitionProperties(
 7508        self,
 7509        dID: base.DecisionID,
 7510        transition: base.Transition
 7511    ) -> TransitionProperties:
 7512        """
 7513        Looks up transition properties for the transition with the given
 7514        name outgoing from the decision with the given ID, in the latest
 7515        step in which a transiiton with that name from that decision
 7516        exists (which will usually be the final exploration step, unless
 7517        transitions get removed/renamed along the way). Note that because
 7518        a transition can be deleted and later added back (unlike
 7519        decisions where an ID will not be re-used), it's possible there
 7520        are two or more different transitions that meet the
 7521        specifications at different points in time, and this will always
 7522        return the properties of the last of them. This will raise a
 7523        `MissingDecisionError` if there is no step at which the specified
 7524        decision exists, and a `MissingTransitionError` if the target
 7525        decision exists at some step but never has a transition with the
 7526        specified name.
 7527        """
 7528        sawDecision: Optional[int] = None
 7529        for step in range(len(self) - 1, -1, -1):
 7530            graph = self.getSituation(step).graph
 7531            try:
 7532                return graph.getTransitionProperties(dID, transition)
 7533            except (MissingDecisionError, MissingTransitionError) as e:
 7534                if (
 7535                    sawDecision is None
 7536                and isinstance(e, MissingTransitionError)
 7537                ):
 7538                    sawDecision = step
 7539                continue
 7540        if sawDecision is None:
 7541            raise MissingDecisionError(
 7542                f"Decision {dID!r} does not exist at any step of the"
 7543                f" exploration."
 7544            )
 7545        else:
 7546            raise MissingTransitionError(
 7547                f"Decision {dID!r} does exist (last seen at step"
 7548                f" {sawDecision}) but it never has an outgoing"
 7549                f" transition named {transition!r}."
 7550            )
 7551
 7552    def tagStep(
 7553        self,
 7554        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 7555        tagValue: Union[
 7556            base.TagValue,
 7557            type[base.NoTagValue]
 7558        ] = base.NoTagValue,
 7559        step: int = -1
 7560    ) -> None:
 7561        """
 7562        Adds a tag (or multiple tags) to the current step, or to a
 7563        specific step if `n` is given as an integer rather than the
 7564        default `None`. A tag value should be supplied when a tag is
 7565        given (unless you want to use the default of `1`), but it's a
 7566        `ValueError` to supply a tag value when a dictionary of tags to
 7567        update is provided.
 7568        """
 7569        if isinstance(tagOrTags, base.Tag):
 7570            if tagValue is base.NoTagValue:
 7571                tagValue = 1
 7572
 7573            # Not sure why this is necessary...
 7574            tagValue = cast(base.TagValue, tagValue)
 7575
 7576            self.getSituation(step).tags.update({tagOrTags: tagValue})
 7577        else:
 7578            self.getSituation(step).tags.update(tagOrTags)
 7579
 7580    def annotateStep(
 7581        self,
 7582        annotationOrAnnotations: Union[
 7583            base.Annotation,
 7584            Sequence[base.Annotation]
 7585        ],
 7586        step: Optional[int] = None
 7587    ) -> None:
 7588        """
 7589        Adds an annotation to the current exploration step, or to a
 7590        specific step if `n` is given as an integer rather than the
 7591        default `None`.
 7592        """
 7593        if step is None:
 7594            step = -1
 7595        if isinstance(annotationOrAnnotations, base.Annotation):
 7596            self.getSituation(step).annotations.append(
 7597                annotationOrAnnotations
 7598            )
 7599        else:
 7600            self.getSituation(step).annotations.extend(
 7601                annotationOrAnnotations
 7602            )
 7603
 7604    def hasCapability(
 7605        self,
 7606        capability: base.Capability,
 7607        step: Optional[int] = None,
 7608        inCommon: Union[bool, Literal['both']] = "both"
 7609    ) -> bool:
 7610        """
 7611        Returns True if the player currently had the specified
 7612        capability, at the specified exploration step, and False
 7613        otherwise. Checks the current state if no step is given. Does
 7614        NOT return true if the game state means that the player has an
 7615        equivalent for that capability (see
 7616        `hasCapabilityOrEquivalent`).
 7617
 7618        Normally, `inCommon` is set to 'both' by default and so if
 7619        either the common `FocalContext` or the active one has the
 7620        capability, this will return `True`. `inCommon` may instead be
 7621        set to `True` or `False` to ask about just the common (or
 7622        active) focal context.
 7623        """
 7624        state = self.getSituation().state
 7625        commonCapabilities = state['common']['capabilities']\
 7626            ['capabilities']  # noqa
 7627        activeCapabilities = state['contexts'][state['activeContext']]\
 7628            ['capabilities']['capabilities']  # noqa
 7629
 7630        if inCommon == 'both':
 7631            return (
 7632                capability in commonCapabilities
 7633             or capability in activeCapabilities
 7634            )
 7635        elif inCommon is True:
 7636            return capability in commonCapabilities
 7637        elif inCommon is False:
 7638            return capability in activeCapabilities
 7639        else:
 7640            raise ValueError(
 7641                f"Invalid inCommon value (must be False, True, or"
 7642                f" 'both'; got {repr(inCommon)})."
 7643            )
 7644
 7645    def hasCapabilityOrEquivalent(
 7646        self,
 7647        capability: base.Capability,
 7648        step: Optional[int] = None,
 7649        location: Optional[Set[base.DecisionID]] = None
 7650    ) -> bool:
 7651        """
 7652        Works like `hasCapability`, but also returns `True` if the
 7653        player counts as having the specified capability via an equivalence
 7654        that's part of the current graph. As with `hasCapability`, the
 7655        optional `step` argument is used to specify which step to check,
 7656        with the current step being used as the default.
 7657
 7658        The `location` set can specify where to start looking for
 7659        mechanisms; if left unspecified active decisions for that step
 7660        will be used.
 7661        """
 7662        if step is None:
 7663            step = -1
 7664        if location is None:
 7665            location = self.getActiveDecisions(step)
 7666        situation = self.getSituation(step)
 7667        return base.hasCapabilityOrEquivalent(
 7668            capability,
 7669            base.RequirementContext(
 7670                state=situation.state,
 7671                graph=situation.graph,
 7672                searchFrom=location
 7673            )
 7674        )
 7675
 7676    def gainCapabilityNow(
 7677        self,
 7678        capability: base.Capability,
 7679        inCommon: bool = False
 7680    ) -> None:
 7681        """
 7682        Modifies the current game state to add the specified `Capability`
 7683        to the player's capabilities. No changes are made to the current
 7684        graph.
 7685
 7686        If `inCommon` is set to `True` (default is `False`) then the
 7687        capability will be added to the common `FocalContext` and will
 7688        therefore persist even when a focal context switch happens.
 7689        Normally, it will be added to the currently-active focal
 7690        context.
 7691        """
 7692        state = self.getSituation().state
 7693        if inCommon:
 7694            context = state['common']
 7695        else:
 7696            context = state['contexts'][state['activeContext']]
 7697        context['capabilities']['capabilities'].add(capability)
 7698
 7699    def loseCapabilityNow(
 7700        self,
 7701        capability: base.Capability,
 7702        inCommon: Union[bool, Literal['both']] = "both"
 7703    ) -> None:
 7704        """
 7705        Modifies the current game state to remove the specified `Capability`
 7706        from the player's capabilities. Does nothing if the player
 7707        doesn't already have that capability.
 7708
 7709        By default, this removes the capability from both the common
 7710        capabilities set and the active `FocalContext`'s capabilities
 7711        set, so that afterwards the player will definitely not have that
 7712        capability. However, if you set `inCommon` to either `True` or
 7713        `False`, it will remove the capability from just the common
 7714        capabilities set (if `True`) or just the active capabilities set
 7715        (if `False`). In these cases, removing the capability from just
 7716        one capability set will not actually remove it in terms of the
 7717        `hasCapability` result if it had been present in the other set.
 7718        Set `inCommon` to "both" to use the default behavior explicitly.
 7719        """
 7720        now = self.getSituation()
 7721        if inCommon in ("both", True):
 7722            context = now.state['common']
 7723            try:
 7724                context['capabilities']['capabilities'].remove(capability)
 7725            except KeyError:
 7726                pass
 7727        elif inCommon in ("both", False):
 7728            context = now.state['contexts'][now.state['activeContext']]
 7729            try:
 7730                context['capabilities']['capabilities'].remove(capability)
 7731            except KeyError:
 7732                pass
 7733        else:
 7734            raise ValueError(
 7735                f"Invalid inCommon value (must be False, True, or"
 7736                f" 'both'; got {repr(inCommon)})."
 7737            )
 7738
 7739    def tokenCountNow(self, tokenType: base.Token) -> Optional[int]:
 7740        """
 7741        Returns the number of tokens the player currently has of a given
 7742        type. Returns `None` if the player has never acquired or lost
 7743        tokens of that type.
 7744
 7745        This method adds together tokens from the common and active
 7746        focal contexts.
 7747        """
 7748        state = self.getSituation().state
 7749        commonContext = state['common']
 7750        activeContext = state['contexts'][state['activeContext']]
 7751        base = commonContext['capabilities']['tokens'].get(tokenType)
 7752        if base is None:
 7753            return activeContext['capabilities']['tokens'].get(tokenType)
 7754        else:
 7755            return base + activeContext['capabilities']['tokens'].get(
 7756                tokenType,
 7757                0
 7758            )
 7759
 7760    def adjustTokensNow(
 7761        self,
 7762        tokenType: base.Token,
 7763        amount: int,
 7764        inCommon: bool = False
 7765    ) -> None:
 7766        """
 7767        Modifies the current game state to add the specified number of
 7768        `Token`s of the given type to the player's tokens. No changes are
 7769        made to the current graph. Reduce the number of tokens by
 7770        supplying a negative amount; note that negative token amounts
 7771        are possible.
 7772
 7773        By default, the number of tokens for the current active
 7774        `FocalContext` will be adjusted. However, if `inCommon` is set
 7775        to `True`, then the number of tokens for the common context will
 7776        be adjusted instead.
 7777        """
 7778        # TODO: Custom token caps!
 7779        state = self.getSituation().state
 7780        if inCommon:
 7781            context = state['common']
 7782        else:
 7783            context = state['contexts'][state['activeContext']]
 7784        tokens = context['capabilities']['tokens']
 7785        tokens[tokenType] = tokens.get(tokenType, 0) + amount
 7786
 7787    def setTokensNow(
 7788        self,
 7789        tokenType: base.Token,
 7790        amount: int,
 7791        inCommon: bool = False
 7792    ) -> None:
 7793        """
 7794        Modifies the current game state to set number of `Token`s of the
 7795        given type to a specific amount, regardless of the old value. No
 7796        changes are made to the current graph.
 7797
 7798        By default this sets the number of tokens for the active
 7799        `FocalContext`. But if you set `inCommon` to `True`, it will
 7800        set the number of tokens in the common context instead.
 7801        """
 7802        # TODO: Custom token caps!
 7803        state = self.getSituation().state
 7804        if inCommon:
 7805            context = state['common']
 7806        else:
 7807            context = state['contexts'][state['activeContext']]
 7808        context['capabilities']['tokens'][tokenType] = amount
 7809
 7810    def lookupMechanism(
 7811        self,
 7812        mechanism: base.MechanismName,
 7813        step: Optional[int] = None,
 7814        where: Union[
 7815            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 7816            Collection[base.AnyDecisionSpecifier],
 7817            None
 7818        ] = None
 7819    ) -> base.MechanismID:
 7820        """
 7821        Looks up a mechanism ID by name, in the graph for the specified
 7822        step. The `where` argument specifies where to start looking,
 7823        which helps disambiguate. It can be a tuple with a decision
 7824        specifier and `None` to start from a single decision, or with a
 7825        decision specifier and a transition name to start from either
 7826        end of that transition. It can also be `None` to look at global
 7827        mechanisms and then all decisions directly, although this
 7828        increases the chance of a `MechanismCollisionError`. Finally, it
 7829        can be some other non-tuple collection of decision specifiers to
 7830        start from that set.
 7831
 7832        If no step is specified, uses the current step.
 7833        """
 7834        if step is None:
 7835            step = -1
 7836        situation = self.getSituation(step)
 7837        graph = situation.graph
 7838        searchFrom: Collection[base.AnyDecisionSpecifier]
 7839        if where is None:
 7840            searchFrom = set()
 7841        elif isinstance(where, tuple):
 7842            if len(where) != 2:
 7843                raise ValueError(
 7844                    f"Mechanism lookup location was a tuple with an"
 7845                    f" invalid length (must be length-2 if it's a"
 7846                    f" tuple):\n  {repr(where)}"
 7847                )
 7848            where = cast(
 7849                Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 7850                where
 7851            )
 7852            if where[1] is None:
 7853                searchFrom = {graph.resolveDecision(where[0])}
 7854            else:
 7855                searchFrom = graph.bothEnds(where[0], where[1])
 7856        else:  # must be a collection of specifiers
 7857            searchFrom = cast(Collection[base.AnyDecisionSpecifier], where)
 7858        return graph.lookupMechanism(searchFrom, mechanism)
 7859
 7860    def mechanismState(
 7861        self,
 7862        mechanism: base.AnyMechanismSpecifier,
 7863        where: Optional[Set[base.DecisionID]] = None,
 7864        step: int = -1
 7865    ) -> Optional[base.MechanismState]:
 7866        """
 7867        Returns the current state for the specified mechanism (or the
 7868        state at the specified step if a step index is given). `where`
 7869        may be provided as a set of decision IDs to indicate where to
 7870        search for the named mechanism, or a mechanism ID may be provided
 7871        in the first place. Mechanism states are properties of a `State`
 7872        but are not associated with focal contexts.
 7873        """
 7874        situation = self.getSituation(step)
 7875        mID = situation.graph.resolveMechanism(mechanism, startFrom=where)
 7876        return situation.state['mechanisms'].get(
 7877            mID,
 7878            base.DEFAULT_MECHANISM_STATE
 7879        )
 7880
 7881    def setMechanismStateNow(
 7882        self,
 7883        mechanism: base.AnyMechanismSpecifier,
 7884        toState: base.MechanismState,
 7885        where: Optional[Set[base.DecisionID]] = None
 7886    ) -> None:
 7887        """
 7888        Sets the state of the specified mechanism to the specified
 7889        state. Mechanisms can only be in one state at once, so this
 7890        removes any previous states for that mechanism (note that via
 7891        equivalences multiple mechanism states can count as active).
 7892
 7893        The mechanism can be any kind of mechanism specifier (see
 7894        `base.AnyMechanismSpecifier`). If it's not a mechanism ID and
 7895        doesn't have its own position information, the 'where' argument
 7896        can be used to hint where to search for the mechanism.
 7897        """
 7898        now = self.getSituation()
 7899        mID = now.graph.resolveMechanism(mechanism, startFrom=where)
 7900        if mID is None:
 7901            raise MissingMechanismError(
 7902                f"Couldn't find mechanism for {repr(mechanism)}."
 7903            )
 7904        now.state['mechanisms'][mID] = toState
 7905
 7906    def skillLevel(
 7907        self,
 7908        skill: base.Skill,
 7909        step: Optional[int] = None
 7910    ) -> Optional[base.Level]:
 7911        """
 7912        Returns the skill level the player had in a given skill at a
 7913        given step, or for the current step if no step is specified.
 7914        Returns `None` if the player had never acquired or lost levels
 7915        in that skill before the specified step (skill level would count
 7916        as 0 in that case).
 7917
 7918        This method adds together levels from the common and active
 7919        focal contexts.
 7920        """
 7921        if step is None:
 7922            step = -1
 7923        state = self.getSituation(step).state
 7924        commonContext = state['common']
 7925        activeContext = state['contexts'][state['activeContext']]
 7926        base = commonContext['capabilities']['skills'].get(skill)
 7927        if base is None:
 7928            return activeContext['capabilities']['skills'].get(skill)
 7929        else:
 7930            return base + activeContext['capabilities']['skills'].get(
 7931                skill,
 7932                0
 7933            )
 7934
 7935    def adjustSkillLevelNow(
 7936        self,
 7937        skill: base.Skill,
 7938        levels: base.Level,
 7939        inCommon: bool = False
 7940    ) -> None:
 7941        """
 7942        Modifies the current game state to add the specified number of
 7943        `Level`s of the given skill. No changes are made to the current
 7944        graph. Reduce the skill level by supplying negative levels; note
 7945        that negative skill levels are possible.
 7946
 7947        By default, the skill level for the current active
 7948        `FocalContext` will be adjusted. However, if `inCommon` is set
 7949        to `True`, then the skill level for the common context will be
 7950        adjusted instead.
 7951        """
 7952        # TODO: Custom level caps?
 7953        state = self.getSituation().state
 7954        if inCommon:
 7955            context = state['common']
 7956        else:
 7957            context = state['contexts'][state['activeContext']]
 7958        skills = context['capabilities']['skills']
 7959        skills[skill] = skills.get(skill, 0) + levels
 7960
 7961    def setSkillLevelNow(
 7962        self,
 7963        skill: base.Skill,
 7964        level: base.Level,
 7965        inCommon: bool = False
 7966    ) -> None:
 7967        """
 7968        Modifies the current game state to set `Skill` `Level` for the
 7969        given skill, regardless of the old value. No changes are made to
 7970        the current graph.
 7971
 7972        By default this sets the skill level for the active
 7973        `FocalContext`. But if you set `inCommon` to `True`, it will set
 7974        the skill level in the common context instead.
 7975        """
 7976        # TODO: Custom level caps?
 7977        state = self.getSituation().state
 7978        if inCommon:
 7979            context = state['common']
 7980        else:
 7981            context = state['contexts'][state['activeContext']]
 7982        skills = context['capabilities']['skills']
 7983        skills[skill] = level
 7984
 7985    def updateRequirementNow(
 7986        self,
 7987        decision: base.AnyDecisionSpecifier,
 7988        transition: base.Transition,
 7989        requirement: Optional[base.Requirement]
 7990    ) -> None:
 7991        """
 7992        Updates the requirement for a specific transition in a specific
 7993        decision. Use `None` to remove the requirement for that edge.
 7994        """
 7995        if requirement is None:
 7996            requirement = base.ReqNothing()
 7997        self.getSituation().graph.setTransitionRequirement(
 7998            decision,
 7999            transition,
 8000            requirement
 8001        )
 8002
 8003    def isTraversable(
 8004        self,
 8005        decision: base.AnyDecisionSpecifier,
 8006        transition: base.Transition,
 8007        step: int = -1
 8008    ) -> bool:
 8009        """
 8010        Returns True if the specified transition from the specified
 8011        decision had its requirement satisfied by the game state at the
 8012        specified step (or at the current step if no step is specified).
 8013        Raises an `IndexError` if the specified step doesn't exist, and
 8014        a `KeyError` if the decision or transition specified does not
 8015        exist in the `DecisionGraph` at that step.
 8016        """
 8017        situation = self.getSituation(step)
 8018        req = situation.graph.getTransitionRequirement(decision, transition)
 8019        ctx = base.contextForTransition(situation, decision, transition)
 8020        fromID = situation.graph.resolveDecision(decision)
 8021        return (
 8022            req.satisfied(ctx)
 8023        and (fromID, transition) not in situation.state['deactivated']
 8024        )
 8025
 8026    def applyTransitionEffect(
 8027        self,
 8028        whichEffect: base.EffectSpecifier,
 8029        moveWhich: Optional[base.FocalPointName] = None
 8030    ) -> Optional[base.DecisionID]:
 8031        """
 8032        Applies an effect attached to a transition, taking charges and
 8033        delay into account based on the current `Situation`.
 8034        Modifies the effect's trigger count (but may not actually
 8035        trigger the effect if the charges and/or delay values indicate
 8036        not to; see `base.doTriggerEffect`).
 8037
 8038        If a specific focal point in a plural-focalized domain is
 8039        triggering the effect, the focal point name should be specified
 8040        via `moveWhich` so that goto `Effect`s can know which focal
 8041        point to move when it's not explicitly specified in the effect.
 8042        TODO: Test this!
 8043
 8044        Returns None most of the time, but if a 'goto', 'bounce', or
 8045        'follow' effect was applied, it returns the decision ID for that
 8046        effect's destination, which would override a transition's normal
 8047        destination. If it returns a destination ID, then the exploration
 8048        state will already have been updated to set the position there,
 8049        and further position updates are not needed.
 8050
 8051        Note that transition effects which update active decisions will
 8052        also update the exploration status of those decisions to
 8053        'exploring' if they had been in an unvisited status (see
 8054        `updatePosition` and `hasBeenVisited`).
 8055
 8056        Note: callers should immediately update situation-based variables
 8057        that might have been changes by a 'revert' effect.
 8058        """
 8059        now = self.getSituation()
 8060        effect, triggerCount = base.doTriggerEffect(now, whichEffect)
 8061        if triggerCount is not None:
 8062            return self.applyExtraneousEffect(
 8063                effect,
 8064                where=whichEffect[:2],
 8065                moveWhich=moveWhich
 8066            )
 8067        else:
 8068            return None
 8069
 8070    def applyExtraneousEffect(
 8071        self,
 8072        effect: base.Effect,
 8073        where: Optional[
 8074            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 8075        ] = None,
 8076        moveWhich: Optional[base.FocalPointName] = None,
 8077        challengePolicy: base.ChallengePolicy = "specified"
 8078    ) -> Optional[base.DecisionID]:
 8079        """
 8080        Applies a single extraneous effect to the state & graph,
 8081        *without* accounting for charges or delay values, since the
 8082        effect is not part of the graph (use `applyTransitionEffect` to
 8083        apply effects that are attached to transitions, which is almost
 8084        always the function you should be using). An associated
 8085        transition for the extraneous effect can be supplied using the
 8086        `where` argument, and effects like 'deactivate' and 'edit' will
 8087        affect it (but the effect's charges and delay values will still
 8088        be ignored).
 8089
 8090        If the effect would change the destination of a transition, the
 8091        altered destination ID is returned: 'bounce' effects return the
 8092        provided decision part of `where`, 'goto' effects return their
 8093        target, and 'follow' effects return the destination followed to
 8094        (possibly via chained follows in the extreme case). In all other
 8095        cases, `None` is returned indicating no change to a normal
 8096        destination.
 8097
 8098        If a specific focal point in a plural-focalized domain is
 8099        triggering the effect, the focal point name should be specified
 8100        via `moveWhich` so that goto `Effect`s can know which focal
 8101        point to move when it's not explicitly specified in the effect.
 8102        TODO: Test this!
 8103
 8104        Note that transition effects which update active decisions will
 8105        also update the exploration status of those decisions to
 8106        'exploring' if they had been in an unvisited status and will
 8107        remove any 'unconfirmed' tag they might still have (see
 8108        `updatePosition` and `hasBeenVisited`).
 8109
 8110        The given `challengePolicy` is applied when traversing further
 8111        transitions due to 'follow' effects.
 8112
 8113        Note: Anyone calling `applyExtraneousEffect` should update any
 8114        situation-based variables immediately after the call, as a
 8115        'revert' effect may have changed the current graph and/or state.
 8116        """
 8117        typ = effect['type']
 8118        value = effect['value']
 8119        applyTo = effect['applyTo']
 8120        inCommon = applyTo == 'common'
 8121
 8122        now = self.getSituation()
 8123
 8124        if where is not None:
 8125            if where[1] is not None:
 8126                searchFrom = now.graph.bothEnds(where[0], where[1])
 8127            else:
 8128                searchFrom = {now.graph.resolveDecision(where[0])}
 8129        else:
 8130            searchFrom = None
 8131
 8132        # Note: Delay and charges are ignored!
 8133
 8134        if typ in ("gain", "lose"):
 8135            value = cast(
 8136                Union[
 8137                    base.Capability,
 8138                    Tuple[base.Token, base.TokenCount],
 8139                    Tuple[Literal['skill'], base.Skill, base.Level],
 8140                ],
 8141                value
 8142            )
 8143            if isinstance(value, base.Capability):
 8144                if typ == "gain":
 8145                    self.gainCapabilityNow(value, inCommon)
 8146                else:
 8147                    self.loseCapabilityNow(value, inCommon)
 8148            elif len(value) == 2:  # must be a token, amount pair
 8149                token, amount = cast(
 8150                    Tuple[base.Token, base.TokenCount],
 8151                    value
 8152                )
 8153                if typ == "lose":
 8154                    amount *= -1
 8155                self.adjustTokensNow(token, amount, inCommon)
 8156            else:  # must be a 'skill', skill, level triple
 8157                _, skill, levels = cast(
 8158                    Tuple[Literal['skill'], base.Skill, base.Level],
 8159                    value
 8160                )
 8161                if typ == "lose":
 8162                    levels *= -1
 8163                self.adjustSkillLevelNow(skill, levels, inCommon)
 8164
 8165        elif typ == "set":
 8166            value = cast(
 8167                Union[
 8168                    Tuple[base.Token, base.TokenCount],
 8169                    Tuple[base.AnyMechanismSpecifier, base.MechanismState],
 8170                    Tuple[Literal['skill'], base.Skill, base.Level],
 8171                ],
 8172                value
 8173            )
 8174            if len(value) == 2:  # must be a token or mechanism pair
 8175                if isinstance(value[1], base.TokenCount):  # token
 8176                    token, amount = cast(
 8177                        Tuple[base.Token, base.TokenCount],
 8178                        value
 8179                    )
 8180                    self.setTokensNow(token, amount, inCommon)
 8181                else: # mechanism
 8182                    mechanism, state = cast(
 8183                        Tuple[
 8184                            base.AnyMechanismSpecifier,
 8185                            base.MechanismState
 8186                        ],
 8187                        value
 8188                    )
 8189                    self.setMechanismStateNow(mechanism, state, searchFrom)
 8190            else:  # must be a 'skill', skill, level triple
 8191                _, skill, level = cast(
 8192                    Tuple[Literal['skill'], base.Skill, base.Level],
 8193                    value
 8194                )
 8195                self.setSkillLevelNow(skill, level, inCommon)
 8196
 8197        elif typ == "toggle":
 8198            # Length-1 list just toggles a capability on/off based on current
 8199            # state (not attending to equivalents):
 8200            if isinstance(value, List):  # capabilities list
 8201                value = cast(List[base.Capability], value)
 8202                if len(value) == 0:
 8203                    raise ValueError(
 8204                        "Toggle effect has empty capabilities list."
 8205                    )
 8206                if len(value) == 1:
 8207                    capability = value[0]
 8208                    if self.hasCapability(capability, inCommon=False):
 8209                        self.loseCapabilityNow(capability, inCommon=False)
 8210                    else:
 8211                        self.gainCapabilityNow(capability)
 8212                else:
 8213                    # Otherwise toggle all powers off, then one on,
 8214                    # based on the first capability that's currently on.
 8215                    # Note we do NOT count equivalences.
 8216
 8217                    # Find first capability that's on:
 8218                    firstIndex: Optional[int] = None
 8219                    for i, capability in enumerate(value):
 8220                        if self.hasCapability(capability):
 8221                            firstIndex = i
 8222                            break
 8223
 8224                    # Turn them all off:
 8225                    for capability in value:
 8226                        self.loseCapabilityNow(capability, inCommon=False)
 8227                        # TODO: inCommon for the check?
 8228
 8229                    if firstIndex is None:
 8230                        self.gainCapabilityNow(value[0])
 8231                    else:
 8232                        self.gainCapabilityNow(
 8233                            value[(firstIndex + 1) % len(value)]
 8234                        )
 8235            else:  # must be a mechanism w/ states list
 8236                mechanism, states = cast(
 8237                    Tuple[
 8238                        base.AnyMechanismSpecifier,
 8239                        List[base.MechanismState]
 8240                    ],
 8241                    value
 8242                )
 8243                currentState = self.mechanismState(mechanism, where=searchFrom)
 8244                if len(states) == 1:
 8245                    if currentState == states[0]:
 8246                        # default alternate state
 8247                        self.setMechanismStateNow(
 8248                            mechanism,
 8249                            base.DEFAULT_MECHANISM_STATE,
 8250                            searchFrom
 8251                        )
 8252                    else:
 8253                        self.setMechanismStateNow(
 8254                            mechanism,
 8255                            states[0],
 8256                            searchFrom
 8257                        )
 8258                else:
 8259                    # Find our position in the list, if any
 8260                    try:
 8261                        currentIndex = states.index(cast(str, currentState))
 8262                        # Cast here just because we know that None will
 8263                        # raise a ValueError but we'll catch it, and we
 8264                        # want to suppress the mypy warning about the
 8265                        # option
 8266                    except ValueError:
 8267                        currentIndex = len(states) - 1
 8268                    # Set next state in list as current state
 8269                    nextIndex = (currentIndex + 1) % len(states)
 8270                    self.setMechanismStateNow(
 8271                        mechanism,
 8272                        states[nextIndex],
 8273                        searchFrom
 8274                    )
 8275
 8276        elif typ == "deactivate":
 8277            if where is None or where[1] is None:
 8278                raise ValueError(
 8279                    "Can't apply a deactivate effect without specifying"
 8280                    " which transition it applies to."
 8281                )
 8282
 8283            decision, transition = cast(
 8284                Tuple[base.AnyDecisionSpecifier, base.Transition],
 8285                where
 8286            )
 8287
 8288            dID = now.graph.resolveDecision(decision)
 8289            now.state['deactivated'].add((dID, transition))
 8290
 8291        elif typ == "edit":
 8292            value = cast(List[List[commands.Command]], value)
 8293            # If there are no blocks, do nothing
 8294            if len(value) > 0:
 8295                # Apply the first block of commands and then rotate the list
 8296                scope: commands.Scope = {}
 8297                if where is not None:
 8298                    here: base.DecisionID = now.graph.resolveDecision(
 8299                        where[0]
 8300                    )
 8301                    outwards: Optional[base.Transition] = where[1]
 8302                    scope['@'] = here
 8303                    scope['@t'] = outwards
 8304                    if outwards is not None:
 8305                        reciprocal = now.graph.getReciprocal(here, outwards)
 8306                        destination = now.graph.getDestination(here, outwards)
 8307                    else:
 8308                        reciprocal = None
 8309                        destination = None
 8310                    scope['@r'] = reciprocal
 8311                    scope['@d'] = destination
 8312                self.runCommandBlock(value[0], scope)
 8313                value.append(value.pop(0))
 8314
 8315        elif typ == "goto":
 8316            if isinstance(value, base.DecisionSpecifier):
 8317                target: base.AnyDecisionSpecifier = value
 8318                # use moveWhich provided as argument
 8319            elif isinstance(value, tuple):
 8320                target, moveWhich = cast(
 8321                    Tuple[base.AnyDecisionSpecifier, base.FocalPointName],
 8322                    value
 8323                )
 8324            else:
 8325                target = cast(base.AnyDecisionSpecifier, value)
 8326                # use moveWhich provided as argument
 8327
 8328            destID = now.graph.resolveDecision(target)
 8329            base.updatePosition(now, destID, applyTo, moveWhich)
 8330            return destID
 8331
 8332        elif typ == "bounce":
 8333            # Just need to let the caller know they should cancel
 8334            if where is None:
 8335                raise ValueError(
 8336                    "Can't apply a 'bounce' effect without a position"
 8337                    " to apply it from."
 8338                )
 8339            return now.graph.resolveDecision(where[0])
 8340
 8341        elif typ == "follow":
 8342            if where is None:
 8343                raise ValueError(
 8344                    f"Can't follow transition {value!r} because there"
 8345                    f" is no position information when applying the"
 8346                    f" effect."
 8347                )
 8348            if where[1] is not None:
 8349                followFrom = now.graph.getDestination(where[0], where[1])
 8350                if followFrom is None:
 8351                    raise ValueError(
 8352                        f"Can't follow transition {value!r} because the"
 8353                        f" position information specifies transition"
 8354                        f" {where[1]!r} from decision"
 8355                        f" {now.graph.identityOf(where[0])} but that"
 8356                        f" transition does not exist."
 8357                    )
 8358            else:
 8359                followFrom = now.graph.resolveDecision(where[0])
 8360
 8361            following = cast(base.Transition, value)
 8362
 8363            followTo = now.graph.getDestination(followFrom, following)
 8364
 8365            if followTo is None:
 8366                raise ValueError(
 8367                    f"Can't follow transition {following!r} because"
 8368                    f" that transition doesn't exist at the specified"
 8369                    f" destination {now.graph.identityOf(followFrom)}."
 8370                )
 8371
 8372            if self.isTraversable(followFrom, following):  # skip if not
 8373                # Perform initial position update before following new
 8374                # transition:
 8375                base.updatePosition(
 8376                    now,
 8377                    followFrom,
 8378                    applyTo,
 8379                    moveWhich
 8380                )
 8381
 8382                # Apply consequences of followed transition
 8383                fullFollowTo = self.applyTransitionConsequence(
 8384                    followFrom,
 8385                    following,
 8386                    moveWhich,
 8387                    challengePolicy
 8388                )
 8389
 8390                # Now update to end of followed transition
 8391                if fullFollowTo is None:
 8392                    base.updatePosition(
 8393                        now,
 8394                        followTo,
 8395                        applyTo,
 8396                        moveWhich
 8397                    )
 8398                    fullFollowTo = followTo
 8399
 8400                # Skip the normal update: we've taken care of that plus more
 8401                return fullFollowTo
 8402            else:
 8403                # Normal position updates still applies since follow
 8404                # transition wasn't possible
 8405                return None
 8406
 8407        elif typ == "save":
 8408            assert isinstance(value, base.SaveSlot)
 8409            now.saves[value] = copy.deepcopy((now.graph, now.state))
 8410
 8411        else:
 8412            raise ValueError(f"Invalid effect type {typ!r}.")
 8413
 8414        return None  # default return value if we didn't return above
 8415
 8416    def applyExtraneousConsequence(
 8417        self,
 8418        consequence: base.Consequence,
 8419        where: Optional[
 8420            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 8421        ] = None,
 8422        moveWhich: Optional[base.FocalPointName] = None
 8423    ) -> Optional[base.DecisionID]:
 8424        """
 8425        Applies an extraneous consequence not associated with a
 8426        transition. Unlike `applyTransitionConsequence`, the provided
 8427        `base.Consequence` must already have observed outcomes (see
 8428        `base.observeChallengeOutcomes`). Returns the decision ID for a
 8429        decision implied by a goto, follow, or bounce effect, or `None`
 8430        if no effect implies a destination.
 8431
 8432        The `where` and `moveWhich` optional arguments specify which
 8433        decision and/or transition to use as the application position,
 8434        and/or which focal point to move. This affects mechanism lookup
 8435        as well as the end position when 'follow' effects are used.
 8436        Specifically:
 8437
 8438        - A 'follow' trigger will search for transitions to follow from
 8439            the destination of the specified transition, or if only a
 8440            decision was supplied, from that decision.
 8441        - Mechanism lookups will start with both ends of the specified
 8442            transition as their search field (or with just the specified
 8443            decision if no transition is included).
 8444
 8445        'bounce' effects will cause an error unless position information
 8446        is provided, and will set the position to the base decision
 8447        provided in `where`.
 8448
 8449        Note: callers should update any situation-based variables
 8450        immediately after calling this as a 'revert' effect could change
 8451        the current graph and/or state and other changes could get lost
 8452        if they get applied to a stale graph/state.
 8453
 8454        # TODO: Examples for goto and follow effects.
 8455        """
 8456        now = self.getSituation()
 8457        searchFrom = set()
 8458        if where is not None:
 8459            if where[1] is not None:
 8460                searchFrom = now.graph.bothEnds(where[0], where[1])
 8461            else:
 8462                searchFrom = {now.graph.resolveDecision(where[0])}
 8463
 8464        context = base.RequirementContext(
 8465            state=now.state,
 8466            graph=now.graph,
 8467            searchFrom=searchFrom
 8468        )
 8469
 8470        effectIndices = base.observedEffects(context, consequence)
 8471        destID = None
 8472        for index in effectIndices:
 8473            effect = base.consequencePart(consequence, index)
 8474            if not isinstance(effect, dict) or 'value' not in effect:
 8475                raise RuntimeError(
 8476                    f"Invalid effect index {index}: Consequence part at"
 8477                    f" that index is not an Effect. Got:\n{effect}"
 8478                )
 8479            effect = cast(base.Effect, effect)
 8480            destID = self.applyExtraneousEffect(
 8481                effect,
 8482                where,
 8483                moveWhich
 8484            )
 8485            # technically this variable is not used later in this
 8486            # function, but the `applyExtraneousEffect` call means it
 8487            # needs an update, so we're doing that in case someone later
 8488            # adds code to this function that uses 'now' after this
 8489            # point.
 8490            now = self.getSituation()
 8491
 8492        return destID
 8493
 8494    def applyTransitionConsequence(
 8495        self,
 8496        decision: base.AnyDecisionSpecifier,
 8497        transition: base.AnyTransition,
 8498        moveWhich: Optional[base.FocalPointName] = None,
 8499        policy: base.ChallengePolicy = "specified",
 8500        fromIndex: Optional[int] = None,
 8501        toIndex: Optional[int] = None
 8502    ) -> Optional[base.DecisionID]:
 8503        """
 8504        Applies the effects of the specified transition to the current
 8505        graph and state, possibly overriding observed outcomes using
 8506        outcomes specified as part of a `base.TransitionWithOutcomes`.
 8507
 8508        The `where` and `moveWhich` function serve the same purpose as
 8509        for `applyExtraneousEffect`. If `where` is `None`, then the
 8510        effects will be applied as extraneous effects, meaning that
 8511        their delay and charges values will be ignored and their trigger
 8512        count will not be tracked. If `where` is supplied
 8513
 8514        Returns either None to indicate that the position update for the
 8515        transition should apply as usual, or a decision ID indicating
 8516        another destination which has already been applied by a
 8517        transition effect.
 8518
 8519        If `fromIndex` and/or `toIndex` are specified, then only effects
 8520        which have indices between those two (inclusive) will be
 8521        applied, and other effects will neither apply nor be updated in
 8522        any way. Note that `onlyPart` does not override the challenge
 8523        policy: if the effects in the specified part are not applied due
 8524        to a challenge outcome, they still won't happen, including
 8525        challenge outcomes outside of that part. Also, outcomes for
 8526        challenges of the entire consequence are re-observed if the
 8527        challenge policy implies it.
 8528
 8529        Note: Anyone calling this should update any situation-based
 8530        variables immediately after the call, as a 'revert' effect may
 8531        have changed the current graph and/or state.
 8532        """
 8533        now = self.getSituation()
 8534        dID = now.graph.resolveDecision(decision)
 8535
 8536        transitionName, outcomes = base.nameAndOutcomes(transition)
 8537
 8538        searchFrom = set()
 8539        searchFrom = now.graph.bothEnds(dID, transitionName)
 8540
 8541        context = base.RequirementContext(
 8542            state=now.state,
 8543            graph=now.graph,
 8544            searchFrom=searchFrom
 8545        )
 8546
 8547        consequence = now.graph.getConsequence(dID, transitionName)
 8548
 8549        # Make sure that challenge outcomes are known
 8550        if policy != "specified":
 8551            base.resetChallengeOutcomes(consequence)
 8552        useUp = outcomes[:]
 8553        base.observeChallengeOutcomes(
 8554            context,
 8555            consequence,
 8556            location=searchFrom,
 8557            policy=policy,
 8558            knownOutcomes=useUp
 8559        )
 8560        if len(useUp) > 0:
 8561            raise ValueError(
 8562                f"More outcomes specified than challenges observed in"
 8563                f" consequence:\n{consequence}"
 8564                f"\nRemaining outcomes:\n{useUp}"
 8565            )
 8566
 8567        # Figure out which effects apply, and apply each of them
 8568        effectIndices = base.observedEffects(context, consequence)
 8569        if fromIndex is None:
 8570            fromIndex = 0
 8571
 8572        altDest = None
 8573        for index in effectIndices:
 8574            if (
 8575                index >= fromIndex
 8576            and (toIndex is None or index <= toIndex)
 8577            ):
 8578                thisDest = self.applyTransitionEffect(
 8579                    (dID, transitionName, index),
 8580                    moveWhich
 8581                )
 8582                if thisDest is not None:
 8583                    altDest = thisDest
 8584                # TODO: What if this updates state with 'revert' to a
 8585                # graph that doesn't contain the same effects?
 8586                # TODO: Update 'now' and 'context'?!
 8587        return altDest
 8588
 8589    def allDecisions(self) -> List[base.DecisionID]:
 8590        """
 8591        Returns the list of all decisions which existed at any point
 8592        within the exploration. Example:
 8593
 8594        >>> ex = DiscreteExploration()
 8595        >>> ex.start('A')
 8596        0
 8597        >>> ex.observe('A', 'right')
 8598        1
 8599        >>> ex.explore('right', 'B', 'left')
 8600        1
 8601        >>> ex.observe('B', 'right')
 8602        2
 8603        >>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
 8604        [0, 1, 2]
 8605        """
 8606        seen = set()
 8607        result = []
 8608        for situation in self:
 8609            for decision in situation.graph:
 8610                if decision not in seen:
 8611                    result.append(decision)
 8612                    seen.add(decision)
 8613
 8614        return result
 8615
 8616    def allExploredDecisions(self) -> List[base.DecisionID]:
 8617        """
 8618        Returns the list of all decisions which existed at any point
 8619        within the exploration, excluding decisions whose highest
 8620        exploration status was `noticed` or lower. May still include
 8621        decisions which don't exist in the final situation's graph due to
 8622        things like decision merging. Example:
 8623
 8624        >>> ex = DiscreteExploration()
 8625        >>> ex.start('A')
 8626        0
 8627        >>> ex.observe('A', 'right')
 8628        1
 8629        >>> ex.explore('right', 'B', 'left')
 8630        1
 8631        >>> ex.observe('B', 'right')
 8632        2
 8633        >>> graph = ex.getSituation().graph
 8634        >>> graph.addDecision('C')  # add isolated decision; doesn't set status
 8635        3
 8636        >>> ex.hasBeenVisited('C')
 8637        False
 8638        >>> ex.allExploredDecisions()
 8639        [0, 1]
 8640        >>> ex.setExplorationStatus('C', 'exploring')
 8641        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
 8642        [0, 1, 3]
 8643        >>> ex.setExplorationStatus('A', 'explored')
 8644        >>> ex.allExploredDecisions()
 8645        [0, 1, 3]
 8646        >>> ex.setExplorationStatus('A', 'unknown')
 8647        >>> # remains visisted in an earlier step
 8648        >>> ex.allExploredDecisions()
 8649        [0, 1, 3]
 8650        >>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
 8651        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
 8652        [0, 1]
 8653        """
 8654        seen = set()
 8655        result = []
 8656        for situation in self:
 8657            graph = situation.graph
 8658            for decision in graph:
 8659                if (
 8660                    decision not in seen
 8661                and base.hasBeenVisited(situation, decision)
 8662                ):
 8663                    result.append(decision)
 8664                    seen.add(decision)
 8665
 8666        return result
 8667
 8668    def allVisitedDecisions(self) -> List[base.DecisionID]:
 8669        """
 8670        Returns the list of all decisions which existed at any point
 8671        within the exploration and which were visited at least once.
 8672        Orders them in the same order they were visited in.
 8673
 8674        Usually all of these decisions will be present in the final
 8675        situation's graph, but sometimes merging or other factors means
 8676        there might be some that won't be. Being present on the game
 8677        state's 'active' list in a step for its domain is what counts as
 8678        "being visited," which means that nodes which were passed through
 8679        directly via a 'follow' effect won't be counted, for example.
 8680
 8681        This should usually correspond with the absence of the
 8682        'unconfirmed' tag.
 8683
 8684        Example:
 8685
 8686        >>> ex = DiscreteExploration()
 8687        >>> ex.start('A')
 8688        0
 8689        >>> ex.observe('A', 'right')
 8690        1
 8691        >>> ex.explore('right', 'B', 'left')
 8692        1
 8693        >>> ex.observe('B', 'right')
 8694        2
 8695        >>> ex.getSituation().graph.addDecision('C')  # add isolated decision
 8696        3
 8697        >>> av = ex.allVisitedDecisions()
 8698        >>> av
 8699        [0, 1]
 8700        >>> all(  # no decisions in the 'visited' list are tagged
 8701        ...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
 8702        ...     for d in av
 8703        ... )
 8704        True
 8705        >>> graph = ex.getSituation().graph
 8706        >>> 'unconfirmed' in graph.decisionTags(0)
 8707        False
 8708        >>> 'unconfirmed' in graph.decisionTags(1)
 8709        False
 8710        >>> 'unconfirmed' in graph.decisionTags(2)
 8711        True
 8712        >>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
 8713        False
 8714        """
 8715        seen = set()
 8716        result = []
 8717        for step in range(len(self)):
 8718            active = self.getActiveDecisions(step)
 8719            for dID in active:
 8720                if dID not in seen:
 8721                    result.append(dID)
 8722                    seen.add(dID)
 8723
 8724        return result
 8725
 8726    def allTransitions(self) -> List[
 8727        Tuple[base.DecisionID, base.Transition, base.DecisionID]
 8728    ]:
 8729        """
 8730        Returns the list of all transitions which existed at any point
 8731        within the exploration, as 3-tuples with source decision ID,
 8732        transition name, and destination decision ID. Note that since
 8733        transitions can be deleted or re-targeted, and a transition name
 8734        can be re-used after being deleted, things can get messy in the
 8735        edges cases. When the same transition name is used in different
 8736        steps with different decision targets, we end up including each
 8737        possible source-transition-destination triple. Example:
 8738
 8739        >>> ex = DiscreteExploration()
 8740        >>> ex.start('A')
 8741        0
 8742        >>> ex.observe('A', 'right')
 8743        1
 8744        >>> ex.explore('right', 'B', 'left')
 8745        1
 8746        >>> ex.observe('B', 'right')
 8747        2
 8748        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
 8749        >>> ex.primaryDecision(0)
 8750        >>> ex.primaryDecision(1)
 8751        0
 8752        >>> ex.primaryDecision(2)
 8753        1
 8754        >>> ex.primaryDecision(3)
 8755        1
 8756        >>> len(ex)
 8757        4
 8758        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
 8759        >>> ex.observe('B', 'down')
 8760        3
 8761        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
 8762        >>> # (now-deleted), and the unnamed 'down from B'
 8763        >>> ex.allDecisions()
 8764        [0, 1, 2, 3]
 8765        >>> for tr in ex.allTransitions():
 8766        ...     print(tr)
 8767        ...
 8768        (0, 'right', 1)
 8769        (1, 'return', 0)
 8770        (1, 'left', 0)
 8771        (1, 'right', 2)
 8772        (2, 'return', 1)
 8773        (1, 'down', 3)
 8774        (3, 'return', 1)
 8775        >>> # Note transitions from now-deleted nodes, and 'return'
 8776        >>> # transitions for unexplored nodes before they get explored
 8777        """
 8778        seen = set()
 8779        result = []
 8780        for situation in self:
 8781            graph = situation.graph
 8782            for (src, dst, transition) in graph.allEdges():  # type:ignore
 8783                trans = (src, transition, dst)
 8784                if trans not in seen:
 8785                    result.append(trans)
 8786                    seen.add(trans)
 8787
 8788        return result
 8789
 8790    def start(
 8791        self,
 8792        decision: base.AnyDecisionSpecifier,
 8793        startCapabilities: Optional[base.CapabilitySet] = None,
 8794        setMechanismStates: Optional[
 8795            Dict[base.MechanismID, base.MechanismState]
 8796        ] = None,
 8797        setCustomState: Optional[dict] = None,
 8798        decisionType: base.DecisionType = "imposed"
 8799    ) -> base.DecisionID:
 8800        """
 8801        Sets the initial position information for a newly-relevant
 8802        domain for the current focal context. Creates a new decision
 8803        if the decision is specified by name or `DecisionSpecifier` and
 8804        that decision doesn't already exist. Returns the decision ID for
 8805        the newly-placed decision (or for the specified decision if it
 8806        already existed).
 8807
 8808        Raises a `BadStart` error if the current focal context already
 8809        has position information for the specified domain.
 8810
 8811        - The given `startCapabilities` replaces any existing
 8812            capabilities for the current focal context, although you can
 8813            leave it as the default `None` to avoid that and retain any
 8814            capabilities that have been set up already.
 8815        - The given `setMechanismStates` and `setCustomState`
 8816            dictionaries override all previous mechanism states & custom
 8817            states in the new situation. Leave these as the default
 8818            `None` to maintain those states.
 8819        - If created, the decision will be placed in the DEFAULT_DOMAIN
 8820            domain unless it's specified as a `base.DecisionSpecifier`
 8821            with a domain part, in which case that domain is used.
 8822        - If specified as a `base.DecisionSpecifier` with a zone part
 8823            and a new decision needs to be created, the decision will be
 8824            added to that zone, creating it at level 0 if necessary,
 8825            although otherwise no zone information will be changed.
 8826        - Resets the decision type to "pending" and the action taken to
 8827            `None`. Sets the decision type of the previous situation to
 8828            'imposed' (or the specified `decisionType`) and sets an
 8829            appropriate 'start' action for that situation.
 8830        - Tags the step with 'start'.
 8831        - Even in a plural- or spreading-focalized domain, you still need
 8832            to pick one decision to start at.
 8833        """
 8834        now = self.getSituation()
 8835
 8836        startID = now.graph.getDecision(decision)
 8837        zone = None
 8838        domain = base.DEFAULT_DOMAIN
 8839        if startID is None:
 8840            if isinstance(decision, base.DecisionID):
 8841                raise MissingDecisionError(
 8842                    f"Cannot start at decision {decision} because no"
 8843                    f" decision with that ID exists. Supply a name or"
 8844                    f" DecisionSpecifier if you need the start decision"
 8845                    f" to be created automatically."
 8846                )
 8847            elif isinstance(decision, base.DecisionName):
 8848                decision = base.DecisionSpecifier(
 8849                    domain=None,
 8850                    zone=None,
 8851                    name=decision
 8852                )
 8853            startID = now.graph.addDecision(
 8854                decision.name,
 8855                domain=decision.domain
 8856            )
 8857            zone = decision.zone
 8858            if decision.domain is not None:
 8859                domain = decision.domain
 8860
 8861        if zone is not None:
 8862            if now.graph.getZoneInfo(zone) is None:
 8863                now.graph.createZone(zone, 0)
 8864            now.graph.addDecisionToZone(startID, zone)
 8865
 8866        action: base.ExplorationAction = (
 8867            'start',
 8868            startID,
 8869            startID,
 8870            domain,
 8871            startCapabilities,
 8872            setMechanismStates,
 8873            setCustomState
 8874        )
 8875
 8876        self.advanceSituation(action, decisionType)
 8877
 8878        return startID
 8879
 8880    def hasBeenVisited(
 8881        self,
 8882        decision: base.AnyDecisionSpecifier,
 8883        step: int = -1
 8884    ):
 8885        """
 8886        Returns whether or not the specified decision has been visited in
 8887        the specified step (default current step).
 8888        """
 8889        return base.hasBeenVisited(self.getSituation(step), decision)
 8890
 8891    def setExplorationStatus(
 8892        self,
 8893        decision: base.AnyDecisionSpecifier,
 8894        status: base.ExplorationStatus,
 8895        upgradeOnly: bool = False
 8896    ):
 8897        """
 8898        Updates the current exploration status of a specific decision in
 8899        the current situation. If `upgradeOnly` is true (default is
 8900        `False` then the update will only apply if the new exploration
 8901        status counts as 'more-explored' than the old one (see
 8902        `base.moreExplored`).
 8903        """
 8904        base.setExplorationStatus(
 8905            self.getSituation(),
 8906            decision,
 8907            status,
 8908            upgradeOnly
 8909        )
 8910
 8911    def getExplorationStatus(
 8912        self,
 8913        decision: base.AnyDecisionSpecifier,
 8914        step: int = -1
 8915    ):
 8916        """
 8917        Returns the exploration status of the specified decision at the
 8918        specified step (default is last step). Decisions whose
 8919        exploration status has never been set will have a default status
 8920        of 'unknown'.
 8921        """
 8922        situation = self.getSituation(step)
 8923        dID = situation.graph.resolveDecision(decision)
 8924        return situation.state['exploration'].get(dID, 'unknown')
 8925
 8926    def deduceTransitionDetailsAtStep(
 8927        self,
 8928        step: int,
 8929        transition: base.Transition,
 8930        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 8931        whichFocus: Optional[base.FocalPointSpecifier] = None,
 8932        inCommon: Union[bool, Literal["auto"]] = "auto"
 8933    ) -> Tuple[
 8934        base.ContextSpecifier,
 8935        base.DecisionID,
 8936        base.DecisionID,
 8937        Optional[base.FocalPointSpecifier]
 8938    ]:
 8939        """
 8940        Given just a transition name which the player intends to take in
 8941        a specific step, deduces the `ContextSpecifier` for which
 8942        context should be updated, the source and destination
 8943        `DecisionID`s for the transition, and if the destination
 8944        decision's domain is plural-focalized, the `FocalPointName`
 8945        specifying which focal point should be moved.
 8946
 8947        Because many of those things are ambiguous, you may get an
 8948        `AmbiguousTransitionError` when things are underspecified, and
 8949        there are options for specifying some of the extra information
 8950        directly:
 8951
 8952        - `fromDecision` may be used to specify the source decision.
 8953        - `whichFocus` may be used to specify the focal point (within a
 8954            particular context/domain) being updated. When focal point
 8955            ambiguity remains and this is unspecified, the
 8956            alphabetically-earliest relevant focal point will be used
 8957            (either among all focal points which activate the source
 8958            decision, if there are any, or among all focal points for
 8959            the entire domain of the destination decision).
 8960        - `inCommon` (a `ContextSpecifier`) may be used to specify which
 8961            context to update. The default of "auto" will cause the
 8962            active context to be selected unless it does not activate
 8963            the source decision, in which case the common context will
 8964            be selected.
 8965
 8966        A `MissingDecisionError` will be raised if there are no current
 8967        active decisions (e.g., before `start` has been called), and a
 8968        `MissingTransitionError` will be raised if the listed transition
 8969        does not exist from any active decision (or from the specified
 8970        decision if `fromDecision` is used).
 8971        """
 8972        now = self.getSituation(step)
 8973        active = self.getActiveDecisions(step)
 8974        if len(active) == 0:
 8975            raise MissingDecisionError(
 8976                f"There are no active decisions from which transition"
 8977                f" {repr(transition)} could be taken at step {step}."
 8978            )
 8979
 8980        # All source/destination decision pairs for transitions with the
 8981        # given transition name.
 8982        allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {}
 8983
 8984        # TODO: When should we be trimming the active decisions to match
 8985        # any alterations to the graph?
 8986        for dID in active:
 8987            outgoing = now.graph.destinationsFrom(dID)
 8988            if transition in outgoing:
 8989                allDecisionPairs[dID] = outgoing[transition]
 8990
 8991        if len(allDecisionPairs) == 0:
 8992            raise MissingTransitionError(
 8993                f"No transitions named {repr(transition)} are outgoing"
 8994                f" from active decisions at step {step}."
 8995                f"\nActive decisions are:"
 8996                f"\n{now.graph.namesListing(active)}"
 8997            )
 8998
 8999        if (
 9000            fromDecision is not None
 9001        and fromDecision not in allDecisionPairs
 9002        ):
 9003            raise MissingTransitionError(
 9004                f"{fromDecision} was specified as the source decision"
 9005                f" for traversing transition {repr(transition)} but"
 9006                f" there is no transition of that name from that"
 9007                f" decision at step {step}."
 9008                f"\nValid source decisions are:"
 9009                f"\n{now.graph.namesListing(allDecisionPairs)}"
 9010            )
 9011        elif fromDecision is not None:
 9012            fromID = now.graph.resolveDecision(fromDecision)
 9013            destID = allDecisionPairs[fromID]
 9014            fromDomain = now.graph.domainFor(fromID)
 9015        elif len(allDecisionPairs) == 1:
 9016            fromID, destID = list(allDecisionPairs.items())[0]
 9017            fromDomain = now.graph.domainFor(fromID)
 9018        else:
 9019            fromID = None
 9020            destID = None
 9021            fromDomain = None
 9022            # Still ambiguous; resolve this below
 9023
 9024        # Use whichFocus if provided
 9025        if whichFocus is not None:
 9026            # Type/value check for whichFocus
 9027            if (
 9028                not isinstance(whichFocus, tuple)
 9029             or len(whichFocus) != 3
 9030             or whichFocus[0] not in ("active", "common")
 9031             or not isinstance(whichFocus[1], base.Domain)
 9032             or not isinstance(whichFocus[2], base.FocalPointName)
 9033            ):
 9034                raise ValueError(
 9035                    f"Invalid whichFocus value {repr(whichFocus)}."
 9036                    f"\nMust be a length-3 tuple with 'active' or 'common'"
 9037                    f" as the first element, a Domain as the second"
 9038                    f" element, and a FocalPointName as the third"
 9039                    f" element."
 9040                )
 9041
 9042            # Resolve focal point specified
 9043            fromID = base.resolvePosition(
 9044                now,
 9045                whichFocus
 9046            )
 9047            if fromID is None:
 9048                raise MissingTransitionError(
 9049                    f"Focal point {repr(whichFocus)} was specified as"
 9050                    f" the transition source, but that focal point does"
 9051                    f" not have a position."
 9052                )
 9053            else:
 9054                destID = now.graph.destination(fromID, transition)
 9055                fromDomain = now.graph.domainFor(fromID)
 9056
 9057        elif fromID is None:  # whichFocus is None, so it can't disambiguate
 9058            raise AmbiguousTransitionError(
 9059                f"Transition {repr(transition)} was selected for"
 9060                f" disambiguation, but there are multiple transitions"
 9061                f" with that name from currently-active decisions, and"
 9062                f" neither fromDecision nor whichFocus adequately"
 9063                f" disambiguates the specific transition taken."
 9064                f"\nValid source decisions at step {step} are:"
 9065                f"\n{now.graph.namesListing(allDecisionPairs)}"
 9066            )
 9067
 9068        # At this point, fromID, destID, and fromDomain have
 9069        # been resolved.
 9070        if fromID is None or destID is None or fromDomain is None:
 9071            raise RuntimeError(
 9072                f"One of fromID, destID, or fromDomain was None after"
 9073                f" disambiguation was finished:"
 9074                f"\nfromID: {fromID}, destID: {destID}, fromDomain:"
 9075                f" {repr(fromDomain)}"
 9076            )
 9077
 9078        # Now figure out which context activated the source so we know
 9079        # which focal point we're moving:
 9080        context = self.getActiveContext()
 9081        active = base.activeDecisionSet(context)
 9082        using: base.ContextSpecifier = "active"
 9083        if fromID not in active:
 9084            context = self.getCommonContext(step)
 9085            using = "common"
 9086
 9087        destDomain = now.graph.domainFor(destID)
 9088        if (
 9089            whichFocus is None
 9090        and base.getDomainFocalization(context, destDomain) == 'plural'
 9091        ):
 9092            # Need to figure out which focal point is moving; use the
 9093            # alphabetically earliest one that's positioned at the
 9094            # fromID, or just the earliest one overall if none of them
 9095            # are there.
 9096            contextFocalPoints: Dict[
 9097                base.FocalPointName,
 9098                Optional[base.DecisionID]
 9099            ] = cast(
 9100                Dict[base.FocalPointName, Optional[base.DecisionID]],
 9101                context['activeDecisions'][destDomain]
 9102            )
 9103            if not isinstance(contextFocalPoints, dict):
 9104                raise RuntimeError(
 9105                    f"Active decisions specifier for domain"
 9106                    f" {repr(destDomain)} with plural focalization has"
 9107                    f" a non-dictionary value."
 9108                )
 9109
 9110            if fromDomain == destDomain:
 9111                focalCandidates = [
 9112                    fp
 9113                    for fp, pos in contextFocalPoints.items()
 9114                    if pos == fromID
 9115                ]
 9116            else:
 9117                focalCandidates = list(contextFocalPoints)
 9118
 9119            whichFocus = (using, destDomain, min(focalCandidates))
 9120
 9121        # Now whichFocus has been set if it wasn't already specified;
 9122        # might still be None if it's not relevant.
 9123        return (using, fromID, destID, whichFocus)
 9124
 9125    def advanceSituation(
 9126        self,
 9127        action: base.ExplorationAction,
 9128        decisionType: base.DecisionType = "active",
 9129        challengePolicy: base.ChallengePolicy = "specified"
 9130    ) -> Tuple[base.Situation, Set[base.DecisionID]]:
 9131        """
 9132        Given an `ExplorationAction`, sets that as the action taken in
 9133        the current situation, and adds a new situation with the results
 9134        of that action. A `DoubleActionError` will be raised if the
 9135        current situation already has an action specified, and/or has a
 9136        decision type other than 'pending'. By default the type of the
 9137        decision will be 'active' but another `DecisionType` can be
 9138        specified via the `decisionType` parameter.
 9139
 9140        If the action specified is `('noAction',)`, then the new
 9141        situation will be a copy of the old one; this represents waiting
 9142        or being at an ending (a decision type other than 'pending'
 9143        should be used).
 9144
 9145        Although `None` can appear as the action entry in situations
 9146        with pending decisions, you cannot call `advanceSituation` with
 9147        `None` as the action.
 9148
 9149        If the action includes taking a transition whose requirements
 9150        are not satisfied, the transition will still be taken (and any
 9151        consequences applied) but a `TransitionBlockedWarning` will be
 9152        issued.
 9153
 9154        A `ChallengePolicy` may be specified, the default is 'specified'
 9155        which requires that outcomes are pre-specified. If any other
 9156        policy is set, the challenge outcomes will be reset before
 9157        re-resolving them according to the provided policy.
 9158
 9159        The new situation will have decision type 'pending' and `None`
 9160        as the action.
 9161
 9162        The new situation created as a result of the action is returned,
 9163        along with the set of destination decision IDs, including
 9164        possibly a modified destination via 'bounce', 'goto', and/or
 9165        'follow' effects. For actions that don't have a destination, the
 9166        second part of the returned tuple will be an empty set. Multiple
 9167        IDs may be in the set when using a start action in a plural- or
 9168        spreading-focalized domain, for example.
 9169
 9170        If the action updates active decisions (including via transition
 9171        effects) this will also update the exploration status of those
 9172        decisions to 'exploring' if they had been in an unvisited
 9173        status (see `updatePosition` and `hasBeenVisited`). This
 9174        includes decisions traveled through but not ultimately arrived
 9175        at via 'follow' effects.
 9176
 9177        If any decisions are active in the `ENDINGS_DOMAIN`, attempting
 9178        to 'warp', 'explore', 'take', or 'start' will raise an
 9179        `InvalidActionError`.
 9180        """
 9181        now = self.getSituation()
 9182        if now.type != 'pending' or now.action is not None:
 9183            raise DoubleActionError(
 9184                f"Attempted to take action {repr(action)} at step"
 9185                f" {len(self) - 1}, but an action and/or decision type"
 9186                f" had already been specified:"
 9187                f"\nAction: {repr(now.action)}"
 9188                f"\nType: {repr(now.type)}"
 9189            )
 9190
 9191        # Update the now situation to add in the decision type and
 9192        # action taken:
 9193        revised = base.Situation(
 9194            now.graph,
 9195            now.state,
 9196            decisionType,
 9197            action,
 9198            now.saves,
 9199            now.tags,
 9200            now.annotations
 9201        )
 9202        self.situations[-1] = revised
 9203
 9204        # Separate update process when reverting (this branch returns)
 9205        if (
 9206            action is not None
 9207        and isinstance(action, tuple)
 9208        and len(action) == 3
 9209        and action[0] == 'revertTo'
 9210        and isinstance(action[1], base.SaveSlot)
 9211        and isinstance(action[2], set)
 9212        and all(isinstance(x, str) for x in action[2])
 9213        ):
 9214            _, slot, aspects = action
 9215            if slot not in now.saves:
 9216                raise KeyError(
 9217                    f"Cannot load save slot {slot!r} because no save"
 9218                    f" data has been established for that slot."
 9219                )
 9220            load = now.saves[slot]
 9221            rGraph, rState = base.revertedState(
 9222                (now.graph, now.state),
 9223                load,
 9224                aspects
 9225            )
 9226            reverted = base.Situation(
 9227                graph=rGraph,
 9228                state=rState,
 9229                type='pending',
 9230                action=None,
 9231                saves=copy.deepcopy(now.saves),
 9232                tags={},
 9233                annotations=[]
 9234            )
 9235            self.situations.append(reverted)
 9236            # Apply any active triggers (edits reverted)
 9237            self.applyActiveTriggers()
 9238            # Figure out destinations set to return
 9239            newDestinations = set()
 9240            newPr = rState['primaryDecision']
 9241            if newPr is not None:
 9242                newDestinations.add(newPr)
 9243            return (reverted, newDestinations)
 9244
 9245        # TODO: These deep copies are expensive time-wise. Can we avoid
 9246        # them? Probably not.
 9247        newGraph = copy.deepcopy(now.graph)
 9248        newState = copy.deepcopy(now.state)
 9249        newSaves = copy.copy(now.saves)  # a shallow copy
 9250        newTags: Dict[base.Tag, base.TagValue] = {}
 9251        newAnnotations: List[base.Annotation] = []
 9252        updated = base.Situation(
 9253            graph=newGraph,
 9254            state=newState,
 9255            type='pending',
 9256            action=None,
 9257            saves=newSaves,
 9258            tags=newTags,
 9259            annotations=newAnnotations
 9260        )
 9261
 9262        targetContext: base.FocalContext
 9263
 9264        # Now that action effects have been imprinted into the updated
 9265        # situation, append it to our situations list
 9266        self.situations.append(updated)
 9267
 9268        # Figure out effects of the action:
 9269        if action is None:
 9270            raise InvalidActionError(
 9271                "None cannot be used as an action when advancing the"
 9272                " situation."
 9273            )
 9274
 9275        aLen = len(action)
 9276
 9277        destIDs = set()
 9278
 9279        if (
 9280            action[0] in ('start', 'take', 'explore', 'warp')
 9281        and any(
 9282                newGraph.domainFor(d) == ENDINGS_DOMAIN
 9283                for d in self.getActiveDecisions()
 9284            )
 9285        ):
 9286            activeEndings = [
 9287                d
 9288                for d in self.getActiveDecisions()
 9289                if newGraph.domainFor(d) == ENDINGS_DOMAIN
 9290            ]
 9291            raise InvalidActionError(
 9292                f"Attempted to {action[0]!r} while an ending was"
 9293                f" active. Active endings are:"
 9294                f"\n{newGraph.namesListing(activeEndings)}"
 9295            )
 9296
 9297        if action == ('noAction',):
 9298            # No updates needed
 9299            pass
 9300
 9301        elif (
 9302            not isinstance(action, tuple)
 9303         or (action[0] not in get_args(base.ExplorationActionType))
 9304         or not (2 <= aLen <= 7)
 9305        ):
 9306            raise InvalidActionError(
 9307                f"Invalid ExplorationAction tuple (must be a tuple that"
 9308                f" starts with an ExplorationActionType and has 2-6"
 9309                f" entries if it's not ('noAction',)):"
 9310                f"\n{repr(action)}"
 9311            )
 9312
 9313        elif action[0] == 'start':
 9314            (
 9315                _,
 9316                positionSpecifier,
 9317                primary,
 9318                domain,
 9319                capabilities,
 9320                mechanismStates,
 9321                customState
 9322            ) = cast(
 9323                Tuple[
 9324                    Literal['start'],
 9325                    Union[
 9326                        base.DecisionID,
 9327                        Dict[base.FocalPointName, base.DecisionID],
 9328                        Set[base.DecisionID]
 9329                    ],
 9330                    Optional[base.DecisionID],
 9331                    base.Domain,
 9332                    Optional[base.CapabilitySet],
 9333                    Optional[Dict[base.MechanismID, base.MechanismState]],
 9334                    Optional[dict]
 9335                ],
 9336                action
 9337            )
 9338            targetContext = newState['contexts'][
 9339                newState['activeContext']
 9340            ]
 9341
 9342            targetFocalization = base.getDomainFocalization(
 9343                targetContext,
 9344                domain
 9345            )  # sets up 'singular' as default if
 9346
 9347            # Check if there are any already-active decisions.
 9348            if targetContext['activeDecisions'][domain] is not None:
 9349                raise BadStart(
 9350                    f"Cannot start in domain {repr(domain)} because"
 9351                    f" that domain already has a position. 'start' may"
 9352                    f" only be used with domains that don't yet have"
 9353                    f" any position information."
 9354                )
 9355
 9356            # Make the domain active
 9357            if domain not in targetContext['activeDomains']:
 9358                targetContext['activeDomains'].add(domain)
 9359
 9360            # Check position info matches focalization type and update
 9361            # exploration statuses
 9362            if isinstance(positionSpecifier, base.DecisionID):
 9363                if targetFocalization != 'singular':
 9364                    raise BadStart(
 9365                        f"Invalid position specifier"
 9366                        f" {repr(positionSpecifier)} (type"
 9367                        f" {type(positionSpecifier)}). Domain"
 9368                        f" {repr(domain)} has {targetFocalization}"
 9369                        f" focalization."
 9370                    )
 9371                base.setExplorationStatus(
 9372                    updated,
 9373                    positionSpecifier,
 9374                    'exploring',
 9375                    upgradeOnly=True
 9376                )
 9377                destIDs.add(positionSpecifier)
 9378            elif isinstance(positionSpecifier, dict):
 9379                if targetFocalization != 'plural':
 9380                    raise BadStart(
 9381                        f"Invalid position specifier"
 9382                        f" {repr(positionSpecifier)} (type"
 9383                        f" {type(positionSpecifier)}). Domain"
 9384                        f" {repr(domain)} has {targetFocalization}"
 9385                        f" focalization."
 9386                    )
 9387                destIDs |= set(positionSpecifier.values())
 9388            elif isinstance(positionSpecifier, set):
 9389                if targetFocalization != 'spreading':
 9390                    raise BadStart(
 9391                        f"Invalid position specifier"
 9392                        f" {repr(positionSpecifier)} (type"
 9393                        f" {type(positionSpecifier)}). Domain"
 9394                        f" {repr(domain)} has {targetFocalization}"
 9395                        f" focalization."
 9396                    )
 9397                destIDs |= positionSpecifier
 9398            else:
 9399                raise TypeError(
 9400                    f"Invalid position specifier"
 9401                    f" {repr(positionSpecifier)} (type"
 9402                    f" {type(positionSpecifier)}). It must be a"
 9403                    f" DecisionID, a dictionary from FocalPointNames to"
 9404                    f" DecisionIDs, or a set of DecisionIDs, according"
 9405                    f" to the focalization of the relevant domain."
 9406                )
 9407
 9408            # Put specified position(s) in place
 9409            # TODO: This cast is really silly...
 9410            targetContext['activeDecisions'][domain] = cast(
 9411                Union[
 9412                    None,
 9413                    base.DecisionID,
 9414                    Dict[base.FocalPointName, Optional[base.DecisionID]],
 9415                    Set[base.DecisionID]
 9416                ],
 9417                positionSpecifier
 9418            )
 9419
 9420            # Set primary decision
 9421            newState['primaryDecision'] = primary
 9422
 9423            # Set capabilities
 9424            if capabilities is not None:
 9425                targetContext['capabilities'] = capabilities
 9426
 9427            # Set mechanism states
 9428            if mechanismStates is not None:
 9429                newState['mechanisms'] = mechanismStates
 9430
 9431            # Set custom state
 9432            if customState is not None:
 9433                newState['custom'] = customState
 9434
 9435        elif action[0] in ('explore', 'take', 'warp'):  # similar handling
 9436            assert (
 9437                len(action) == 3
 9438             or len(action) == 4
 9439             or len(action) == 6
 9440             or len(action) == 7
 9441            )
 9442            # Set up necessary variables
 9443            cSpec: base.ContextSpecifier = "active"
 9444            fromID: Optional[base.DecisionID] = None
 9445            takeTransition: Optional[base.Transition] = None
 9446            outcomes: List[bool] = []
 9447            destID: base.DecisionID  # No starting value as it's not optional
 9448            moveInDomain: Optional[base.Domain] = None
 9449            moveWhich: Optional[base.FocalPointName] = None
 9450
 9451            # Figure out target context
 9452            if isinstance(action[1], str):
 9453                if action[1] not in get_args(base.ContextSpecifier):
 9454                    raise InvalidActionError(
 9455                        f"Action specifies {repr(action[1])} context,"
 9456                        f" but that's not a valid context specifier."
 9457                        f" The valid options are:"
 9458                        f"\n{repr(get_args(base.ContextSpecifier))}"
 9459                    )
 9460                else:
 9461                    cSpec = cast(base.ContextSpecifier, action[1])
 9462            else:  # Must be a `FocalPointSpecifier`
 9463                cSpec, moveInDomain, moveWhich = cast(
 9464                    base.FocalPointSpecifier,
 9465                    action[1]
 9466                )
 9467                assert moveInDomain is not None
 9468
 9469            # Grab target context to work in
 9470            if cSpec == 'common':
 9471                targetContext = newState['common']
 9472            else:
 9473                targetContext = newState['contexts'][
 9474                    newState['activeContext']
 9475                ]
 9476
 9477            # Check focalization of the target domain
 9478            if moveInDomain is not None:
 9479                fType = base.getDomainFocalization(
 9480                    targetContext,
 9481                    moveInDomain
 9482                )
 9483                if (
 9484                    (
 9485                        isinstance(action[1], str)
 9486                    and fType == 'plural'
 9487                    ) or (
 9488                        not isinstance(action[1], str)
 9489                    and fType != 'plural'
 9490                    )
 9491                ):
 9492                    raise ImpossibleActionError(
 9493                        f"Invalid ExplorationAction (moves in"
 9494                        f" plural-focalized domains must include a"
 9495                        f" FocalPointSpecifier, while moves in"
 9496                        f" non-plural-focalized domains must not."
 9497                        f" Domain {repr(moveInDomain)} is"
 9498                        f" {fType}-focalized):"
 9499                        f"\n{repr(action)}"
 9500                    )
 9501
 9502            if action[0] == "warp":
 9503                # It's a warp, so destination is specified directly
 9504                if not isinstance(action[2], base.DecisionID):
 9505                    raise TypeError(
 9506                        f"Invalid ExplorationAction tuple (third part"
 9507                        f" must be a decision ID for 'warp' actions):"
 9508                        f"\n{repr(action)}"
 9509                    )
 9510                else:
 9511                    destID = cast(base.DecisionID, action[2])
 9512
 9513            elif aLen == 4 or aLen == 7:
 9514                # direct 'take' or 'explore'
 9515                fromID = cast(base.DecisionID, action[2])
 9516                takeTransition, outcomes = cast(
 9517                    base.TransitionWithOutcomes,
 9518                    action[3]  # type: ignore [misc]
 9519                )
 9520                if (
 9521                    not isinstance(fromID, base.DecisionID)
 9522                 or not isinstance(takeTransition, base.Transition)
 9523                ):
 9524                    raise InvalidActionError(
 9525                        f"Invalid ExplorationAction tuple (for 'take' or"
 9526                        f" 'explore', if the length is 4/7, parts 2-4"
 9527                        f" must be a context specifier, a decision ID, and a"
 9528                        f" transition name. Got:"
 9529                        f"\n{repr(action)}"
 9530                    )
 9531
 9532                try:
 9533                    destID = newGraph.destination(fromID, takeTransition)
 9534                except MissingDecisionError:
 9535                    raise ImpossibleActionError(
 9536                        f"Invalid ExplorationAction: move from decision"
 9537                        f" {fromID} is invalid because there is no"
 9538                        f" decision with that ID in the current"
 9539                        f" graph."
 9540                        f"\nValid decisions are:"
 9541                        f"\n{newGraph.namesListing(newGraph)}"
 9542                    )
 9543                except MissingTransitionError:
 9544                    valid = newGraph.destinationsFrom(fromID)
 9545                    listing = newGraph.destinationsListing(valid)
 9546                    raise ImpossibleActionError(
 9547                        f"Invalid ExplorationAction: move from decision"
 9548                        f" {newGraph.identityOf(fromID)}"
 9549                        f" along transition {repr(takeTransition)} is"
 9550                        f" invalid because there is no such transition"
 9551                        f" at that decision."
 9552                        f"\nValid transitions there are:"
 9553                        f"\n{listing}"
 9554                    )
 9555                targetActive = targetContext['activeDecisions']
 9556                if moveInDomain is not None:
 9557                    activeInDomain = targetActive[moveInDomain]
 9558                    if (
 9559                        (
 9560                            isinstance(activeInDomain, base.DecisionID)
 9561                        and fromID != activeInDomain
 9562                        )
 9563                     or (
 9564                            isinstance(activeInDomain, set)
 9565                        and fromID not in activeInDomain
 9566                        )
 9567                     or (
 9568                            isinstance(activeInDomain, dict)
 9569                        and fromID not in activeInDomain.values()
 9570                        )
 9571                    ):
 9572                        raise ImpossibleActionError(
 9573                            f"Invalid ExplorationAction: move from"
 9574                            f" decision {fromID} is invalid because"
 9575                            f" that decision is not active in domain"
 9576                            f" {repr(moveInDomain)} in the current"
 9577                            f" graph."
 9578                            f"\nValid decisions are:"
 9579                            f"\n{newGraph.namesListing(newGraph)}"
 9580                        )
 9581
 9582            elif aLen == 3 or aLen == 6:
 9583                # 'take' or 'explore' focal point
 9584                # We know that moveInDomain is not None here.
 9585                assert moveInDomain is not None
 9586                if not isinstance(action[2], base.Transition):
 9587                    raise InvalidActionError(
 9588                        f"Invalid ExplorationAction tuple (for 'take'"
 9589                        f" actions if the second part is a"
 9590                        f" FocalPointSpecifier the third part must be a"
 9591                        f" transition name):"
 9592                        f"\n{repr(action)}"
 9593                    )
 9594
 9595                takeTransition, outcomes = cast(
 9596                    base.TransitionWithOutcomes,
 9597                    action[2]
 9598                )
 9599                targetActive = targetContext['activeDecisions']
 9600                activeInDomain = cast(
 9601                    Dict[base.FocalPointName, Optional[base.DecisionID]],
 9602                    targetActive[moveInDomain]
 9603                )
 9604                if (
 9605                    moveInDomain is not None
 9606                and (
 9607                        not isinstance(activeInDomain, dict)
 9608                     or moveWhich not in activeInDomain
 9609                    )
 9610                ):
 9611                    raise ImpossibleActionError(
 9612                        f"Invalid ExplorationAction: move of focal"
 9613                        f" point {repr(moveWhich)} in domain"
 9614                        f" {repr(moveInDomain)} is invalid because"
 9615                        f" that domain does not have a focal point"
 9616                        f" with that name."
 9617                    )
 9618                fromID = activeInDomain[moveWhich]
 9619                if fromID is None:
 9620                    raise ImpossibleActionError(
 9621                        f"Invalid ExplorationAction: move of focal"
 9622                        f" point {repr(moveWhich)} in domain"
 9623                        f" {repr(moveInDomain)} is invalid because"
 9624                        f" that focal point does not have a position"
 9625                        f" at this step."
 9626                    )
 9627                try:
 9628                    destID = newGraph.destination(fromID, takeTransition)
 9629                except MissingDecisionError:
 9630                    raise ImpossibleActionError(
 9631                        f"Invalid exploration state: focal point"
 9632                        f" {repr(moveWhich)} in domain"
 9633                        f" {repr(moveInDomain)} specifies decision"
 9634                        f" {fromID} as the current position, but"
 9635                        f" that decision does not exist!"
 9636                    )
 9637                except MissingTransitionError:
 9638                    valid = newGraph.destinationsFrom(fromID)
 9639                    listing = newGraph.destinationsListing(valid)
 9640                    raise ImpossibleActionError(
 9641                        f"Invalid ExplorationAction: move of focal"
 9642                        f" point {repr(moveWhich)} in domain"
 9643                        f" {repr(moveInDomain)} along transition"
 9644                        f" {repr(takeTransition)} is invalid because"
 9645                        f" that focal point is at decision"
 9646                        f" {newGraph.identityOf(fromID)} and that"
 9647                        f" decision does not have an outgoing"
 9648                        f" transition with that name.\nValid"
 9649                        f" transitions from that decision are:"
 9650                        f"\n{listing}"
 9651                    )
 9652
 9653            else:
 9654                raise InvalidActionError(
 9655                    f"Invalid ExplorationAction: unrecognized"
 9656                    f" 'explore', 'take' or 'warp' format:"
 9657                    f"\n{action}"
 9658                )
 9659
 9660            # If we're exploring, update information for the destination
 9661            if action[0] == 'explore':
 9662                zone = cast(Optional[base.Zone], action[-1])
 9663                recipName = cast(Optional[base.Transition], action[-2])
 9664                destOrName = cast(
 9665                    Union[base.DecisionName, base.DecisionID, None],
 9666                    action[-3]
 9667                )
 9668                if isinstance(destOrName, base.DecisionID):
 9669                    destID = destOrName
 9670
 9671                if fromID is None or takeTransition is None:
 9672                    raise ImpossibleActionError(
 9673                        f"Invalid ExplorationAction: exploration"
 9674                        f" has unclear origin decision or transition."
 9675                        f" Got:\n{action}"
 9676                    )
 9677
 9678                currentDest = newGraph.destination(fromID, takeTransition)
 9679                if not newGraph.isConfirmed(currentDest):
 9680                    newGraph.replaceUnconfirmed(
 9681                        fromID,
 9682                        takeTransition,
 9683                        destOrName,
 9684                        recipName,
 9685                        placeInZone=zone,
 9686                        forceNew=not isinstance(destOrName, base.DecisionID)
 9687                    )
 9688                else:
 9689                    # Otherwise, since the destination already existed
 9690                    # and was hooked up at the right decision, no graph
 9691                    # edits need to be made, unless we need to rename
 9692                    # the reciprocal.
 9693                    # TODO: Do we care about zones here?
 9694                    if recipName is not None:
 9695                        oldReciprocal = newGraph.getReciprocal(
 9696                            fromID,
 9697                            takeTransition
 9698                        )
 9699                        if (
 9700                            oldReciprocal is not None
 9701                        and oldReciprocal != recipName
 9702                        ):
 9703                            newGraph.addTransition(
 9704                                destID,
 9705                                recipName,
 9706                                fromID,
 9707                                None
 9708                            )
 9709                            newGraph.setReciprocal(
 9710                                destID,
 9711                                recipName,
 9712                                takeTransition,
 9713                                setBoth=True
 9714                            )
 9715                            newGraph.mergeTransitions(
 9716                                destID,
 9717                                oldReciprocal,
 9718                                recipName
 9719                            )
 9720
 9721            # If we are moving along a transition, check requirements
 9722            # and apply transition effects *before* updating our
 9723            # position, and check that they don't cancel the normal
 9724            # position update
 9725            finalDest = None
 9726            if takeTransition is not None:
 9727                assert fromID is not None  # both or neither
 9728                if not self.isTraversable(fromID, takeTransition):
 9729                    req = now.graph.getTransitionRequirement(
 9730                        fromID,
 9731                        takeTransition
 9732                    )
 9733                    # TODO: Alter warning message if transition is
 9734                    # deactivated vs. requirement not satisfied
 9735                    warnings.warn(
 9736                        (
 9737                            f"The requirements for transition"
 9738                            f" {takeTransition!r} from decision"
 9739                            f" {now.graph.identityOf(fromID)} are"
 9740                            f" not met at step {len(self) - 1} (or that"
 9741                            f" transition has been deactivated):\n{req}"
 9742                        ),
 9743                        TransitionBlockedWarning
 9744                    )
 9745
 9746                # Apply transition consequences to our new state and
 9747                # figure out if we need to skip our normal update or not
 9748                finalDest = self.applyTransitionConsequence(
 9749                    fromID,
 9750                    (takeTransition, outcomes),
 9751                    moveWhich,
 9752                    challengePolicy
 9753                )
 9754
 9755            # Check moveInDomain
 9756            destDomain = newGraph.domainFor(destID)
 9757            if moveInDomain is not None and moveInDomain != destDomain:
 9758                raise ImpossibleActionError(
 9759                    f"Invalid ExplorationAction: move specified"
 9760                    f" domain {repr(moveInDomain)} as the domain of"
 9761                    f" the focal point to move, but the destination"
 9762                    f" of the move is {now.graph.identityOf(destID)}"
 9763                    f" which is in domain {repr(destDomain)}, so focal"
 9764                    f" point {repr(moveWhich)} cannot be moved there."
 9765                )
 9766
 9767            # Now that we know where we're going, update position
 9768            # information (assuming it wasn't already set):
 9769            if finalDest is None:
 9770                finalDest = destID
 9771                base.updatePosition(
 9772                    updated,
 9773                    destID,
 9774                    cSpec,
 9775                    moveWhich
 9776                )
 9777
 9778            destIDs.add(finalDest)
 9779
 9780        elif action[0] == "focus":
 9781            # Figure out target context
 9782            action = cast(
 9783                Tuple[
 9784                    Literal['focus'],
 9785                    base.ContextSpecifier,
 9786                    Set[base.Domain],
 9787                    Set[base.Domain]
 9788                ],
 9789                action
 9790            )
 9791            contextSpecifier: base.ContextSpecifier = action[1]
 9792            if contextSpecifier == 'common':
 9793                targetContext = newState['common']
 9794            else:
 9795                targetContext = newState['contexts'][
 9796                    newState['activeContext']
 9797                ]
 9798
 9799            # Just need to swap out active domains
 9800            goingOut, comingIn = cast(
 9801                Tuple[Set[base.Domain], Set[base.Domain]],
 9802                action[2:]
 9803            )
 9804            if (
 9805                not isinstance(goingOut, set)
 9806             or not isinstance(comingIn, set)
 9807             or not all(isinstance(d, base.Domain) for d in goingOut)
 9808             or not all(isinstance(d, base.Domain) for d in comingIn)
 9809            ):
 9810                raise InvalidActionError(
 9811                    f"Invalid ExplorationAction tuple (must have 4"
 9812                    f" parts if the first part is 'focus' and"
 9813                    f" the third and fourth parts must be sets of"
 9814                    f" domains):"
 9815                    f"\n{repr(action)}"
 9816                )
 9817            activeSet = targetContext['activeDomains']
 9818            for dom in goingOut:
 9819                try:
 9820                    activeSet.remove(dom)
 9821                except KeyError:
 9822                    warnings.warn(
 9823                        (
 9824                            f"Domain {repr(dom)} was deactivated at"
 9825                            f" step {len(self)} but it was already"
 9826                            f" inactive at that point."
 9827                        ),
 9828                        InactiveDomainWarning
 9829                    )
 9830            # TODO: Also warn for doubly-activated domains?
 9831            activeSet |= comingIn
 9832
 9833            # destIDs remains empty in this case
 9834
 9835        elif action[0] == 'swap':  # update which `FocalContext` is active
 9836            newContext = cast(base.FocalContextName, action[1])
 9837            if newContext not in newState['contexts']:
 9838                raise MissingFocalContextError(
 9839                    f"'swap' action with target {repr(newContext)} is"
 9840                    f" invalid because no context with that name"
 9841                    f" exists."
 9842                )
 9843            newState['activeContext'] = newContext
 9844
 9845            # destIDs remains empty in this case
 9846
 9847        elif action[0] == 'focalize':  # create new `FocalContext`
 9848            newContext = cast(base.FocalContextName, action[1])
 9849            if newContext in newState['contexts']:
 9850                raise FocalContextCollisionError(
 9851                    f"'focalize' action with target {repr(newContext)}"
 9852                    f" is invalid because a context with that name"
 9853                    f" already exists."
 9854                )
 9855            newState['contexts'][newContext] = base.emptyFocalContext()
 9856            newState['activeContext'] = newContext
 9857
 9858            # destIDs remains empty in this case
 9859
 9860        # revertTo is handled above
 9861        else:
 9862            raise InvalidActionError(
 9863                f"Invalid ExplorationAction tuple (first item must be"
 9864                f" an ExplorationActionType, and tuple must be length-1"
 9865                f" if the action type is 'noAction'):"
 9866                f"\n{repr(action)}"
 9867            )
 9868
 9869        # Apply any active triggers
 9870        followTo = self.applyActiveTriggers()
 9871        if followTo is not None:
 9872            destIDs.add(followTo)
 9873            # TODO: Re-work to work with multiple position updates in
 9874            # different focal contexts, domains, and/or for different
 9875            # focal points in plural-focalized domains.
 9876
 9877        return (updated, destIDs)
 9878
 9879    def applyActiveTriggers(self) -> Optional[base.DecisionID]:
 9880        """
 9881        Finds all actions with the 'trigger' tag attached to currently
 9882        active decisions, and applies their effects if their requirements
 9883        are met (ordered by decision-ID with ties broken alphabetically
 9884        by action name).
 9885
 9886        'bounce', 'goto' and 'follow' effects may apply. However, any
 9887        new triggers that would be activated because of decisions
 9888        reached by such effects will not apply. Note that 'bounce'
 9889        effects update position to the decision where the action was
 9890        attached, which is usually a no-op. This function returns the
 9891        decision ID of the decision reached by the last decision-moving
 9892        effect applied, or `None` if no such effects triggered.
 9893
 9894        TODO: What about situations where positions are updated in
 9895        multiple domains or multiple foal points in a plural domain are
 9896        independently updated?
 9897
 9898        TODO: Tests for this!
 9899        """
 9900        active = self.getActiveDecisions()
 9901        now = self.getSituation()
 9902        graph = now.graph
 9903        finalFollow = None
 9904        for decision in sorted(active):
 9905            for action in graph.decisionActions(decision):
 9906                if (
 9907                    'trigger' in graph.transitionTags(decision, action)
 9908                and self.isTraversable(decision, action)
 9909                ):
 9910                    followTo = self.applyTransitionConsequence(
 9911                        decision,
 9912                        action
 9913                    )
 9914                    if followTo is not None:
 9915                        # TODO: How will triggers interact with
 9916                        # plural-focalized domains? Probably need to fix
 9917                        # this to detect moveWhich based on which focal
 9918                        # points are at the decision where the transition
 9919                        # is, and then apply this to each of them?
 9920                        base.updatePosition(now, followTo)
 9921                        finalFollow = followTo
 9922
 9923        return finalFollow
 9924
 9925    def explore(
 9926        self,
 9927        transition: base.AnyTransition,
 9928        destination: Union[base.DecisionName, base.DecisionID, None],
 9929        reciprocal: Optional[base.Transition] = None,
 9930        zone: Optional[base.Zone] = base.DefaultZone,
 9931        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9932        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9933        inCommon: Union[bool, Literal["auto"]] = "auto",
 9934        decisionType: base.DecisionType = "active",
 9935        challengePolicy: base.ChallengePolicy = "specified"
 9936    ) -> base.DecisionID:
 9937        """
 9938        Adds a new situation to the exploration representing the
 9939        traversal of the specified transition (possibly with outcomes
 9940        specified for challenges among that transitions consequences).
 9941        Uses `deduceTransitionDetailsAtStep` to figure out from the
 9942        transition name which specific transition is taken (and which
 9943        focal point is updated if necessary). This uses the
 9944        `fromDecision`, `whichFocus`, and `inCommon` optional
 9945        parameters, and also determines whether to update the common or
 9946        the active `FocalContext`. Sets the exploration status of the
 9947        decision explored to 'exploring'. Returns the decision ID for
 9948        the destination reached, accounting for goto/bounce/follow
 9949        effects that might have triggered.
 9950
 9951        The `destination` will be used to name the newly-explored
 9952        decision, except when it's a `DecisionID`, in which case that
 9953        decision must be unvisited, and we'll connect the specified
 9954        transition to that decision.
 9955
 9956        The focalization of the destination domain in the context to be
 9957        updated determines how active decisions are changed:
 9958
 9959        - If the destination domain is focalized as 'single', then in
 9960            the subsequent `Situation`, the destination decision will
 9961            become the single active decision in that domain.
 9962        - If it's focalized as 'plural', then one of the
 9963            `FocalPointName`s for that domain will be moved to activate
 9964            that decision; which one can be specified using `whichFocus`
 9965            or if left unspecified, will be deduced: if the starting
 9966            decision is in the same domain, then the
 9967            alphabetically-earliest focal point which is at the starting
 9968            decision will be moved. If the starting position is in a
 9969            different domain, then the alphabetically earliest focal
 9970            point among all focal points in the destination domain will
 9971            be moved.
 9972        - If it's focalized as 'spreading', then the destination
 9973            decision will be added to the set of active decisions in
 9974            that domain, without removing any.
 9975
 9976        The transition named must have been pointing to an unvisited
 9977        decision (see `hasBeenVisited`), and the name of that decision
 9978        will be updated if a `destination` value is given (a
 9979        `DecisionCollisionWarning` will be issued if the destination
 9980        name is a duplicate of another name in the graph, although this
 9981        is not an error). Additionally:
 9982
 9983        - If a `reciprocal` name is specified, the reciprocal transition
 9984            will be renamed using that name, or created with that name if
 9985            it didn't already exist. If reciprocal is left as `None` (the
 9986            default) then no change will be made to the reciprocal
 9987            transition, and it will not be created if it doesn't exist.
 9988        - If a `zone` is specified, the newly-explored decision will be
 9989            added to that zone (and that zone will be created at level 0
 9990            if it didn't already exist). If `zone` is set to `None` then
 9991            it will not be added to any new zones. If `zone` is left as
 9992            the default (the `base.DefaultZone` value) then the explored
 9993            decision will be added to each zone that the decision it was
 9994            explored from is a part of. If a zone needs to be created,
 9995            that zone will be added as a sub-zone of each zone which is a
 9996            parent of a zone that directly contains the origin decision.
 9997        - An `ExplorationStatusError` will be raised if the specified
 9998            transition leads to a decision whose `ExplorationStatus` is
 9999            'exploring' or higher (i.e., `hasBeenVisited`). (Use
10000            `returnTo` instead to adjust things when a transition to an
10001            unknown destination turns out to lead to an already-known
10002            destination.)
10003        - A `TransitionBlockedWarning` will be issued if the specified
10004            transition is not traversable given the current game state
10005            (but in that last case the step will still be taken).
10006        - By default, the decision type for the new step will be
10007            'active', but a `decisionType` value can be specified to
10008            override that.
10009        - By default, the 'mostLikely' `ChallengePolicy` will be used to
10010            resolve challenges in the consequence of the transition
10011            taken, but an alternate policy can be supplied using the
10012            `challengePolicy` argument.
10013        """
10014        now = self.getSituation()
10015
10016        transitionName, outcomes = base.nameAndOutcomes(transition)
10017
10018        # Deduce transition details from the name + optional specifiers
10019        (
10020            using,
10021            fromID,
10022            destID,
10023            whichFocus
10024        ) = self.deduceTransitionDetailsAtStep(
10025            -1,
10026            transitionName,
10027            fromDecision,
10028            whichFocus,
10029            inCommon
10030        )
10031
10032        # Issue a warning if the destination name is already in use
10033        if destination is not None:
10034            if isinstance(destination, base.DecisionName):
10035                try:
10036                    existingID = now.graph.resolveDecision(destination)
10037                    collision = existingID != destID
10038                except MissingDecisionError:
10039                    collision = False
10040                except AmbiguousDecisionSpecifierError:
10041                    collision = True
10042
10043                if collision and WARN_OF_NAME_COLLISIONS:
10044                    warnings.warn(
10045                        (
10046                            f"The destination name {repr(destination)} is"
10047                            f" already in use when exploring transition"
10048                            f" {repr(transition)} from decision"
10049                            f" {now.graph.identityOf(fromID)} at step"
10050                            f" {len(self) - 1}."
10051                        ),
10052                        DecisionCollisionWarning
10053                    )
10054
10055        # TODO: Different terminology for "exploration state above
10056        # noticed" vs. "DG thinks it's been visited"...
10057        if (
10058            self.hasBeenVisited(destID)
10059        ):
10060            raise ExplorationStatusError(
10061                f"Cannot explore to decision"
10062                f" {now.graph.identityOf(destID)} because it has"
10063                f" already been visited. Use returnTo instead of"
10064                f" explore when discovering a connection back to a"
10065                f" previously-explored decision."
10066            )
10067
10068        if (
10069            isinstance(destination, base.DecisionID)
10070        and self.hasBeenVisited(destination)
10071        ):
10072            raise ExplorationStatusError(
10073                f"Cannot explore to decision"
10074                f" {now.graph.identityOf(destination)} because it has"
10075                f" already been visited. Use returnTo instead of"
10076                f" explore when discovering a connection back to a"
10077                f" previously-explored decision."
10078            )
10079
10080        actionTaken: base.ExplorationAction = (
10081            'explore',
10082            using,
10083            fromID,
10084            (transitionName, outcomes),
10085            destination,
10086            reciprocal,
10087            zone
10088        )
10089        if whichFocus is not None:
10090            # A move-from-specific-focal-point action
10091            actionTaken = (
10092                'explore',
10093                whichFocus,
10094                (transitionName, outcomes),
10095                destination,
10096                reciprocal,
10097                zone
10098            )
10099
10100        # Advance the situation, applying transition effects and
10101        # updating the destination decision.
10102        _, finalDest = self.advanceSituation(
10103            actionTaken,
10104            decisionType,
10105            challengePolicy
10106        )
10107
10108        # TODO: Is this assertion always valid?
10109        assert len(finalDest) == 1
10110        return next(x for x in finalDest)
10111
10112    def returnTo(
10113        self,
10114        transition: base.AnyTransition,
10115        destination: base.AnyDecisionSpecifier,
10116        reciprocal: Optional[base.Transition] = None,
10117        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10118        whichFocus: Optional[base.FocalPointSpecifier] = None,
10119        inCommon: Union[bool, Literal["auto"]] = "auto",
10120        decisionType: base.DecisionType = "active",
10121        challengePolicy: base.ChallengePolicy = "specified"
10122    ) -> base.DecisionID:
10123        """
10124        Adds a new graph to the exploration that replaces the given
10125        transition at the current position (which must lead to an unknown
10126        node, or a `MissingDecisionError` will result). The new
10127        transition will connect back to the specified destination, which
10128        must already exist (or a different `ValueError` will be raised).
10129        Returns the decision ID for the destination reached.
10130
10131        Deduces transition details using the optional `fromDecision`,
10132        `whichFocus`, and `inCommon` arguments in addition to the
10133        `transition` value; see `deduceTransitionDetailsAtStep`.
10134
10135        If a `reciprocal` transition is specified, that transition must
10136        either not already exist in the destination decision or lead to
10137        an unknown region; it will be replaced (or added) as an edge
10138        leading back to the current position.
10139
10140        The `decisionType` and `challengePolicy` optional arguments are
10141        used for `advanceSituation`.
10142
10143        A `TransitionBlockedWarning` will be issued if the requirements
10144        for the transition are not met, but the step will still be taken.
10145        Raises a `MissingDecisionError` if there is no current
10146        transition.
10147        """
10148        now = self.getSituation()
10149
10150        transitionName, outcomes = base.nameAndOutcomes(transition)
10151
10152        # Deduce transition details from the name + optional specifiers
10153        (
10154            using,
10155            fromID,
10156            destID,
10157            whichFocus
10158        ) = self.deduceTransitionDetailsAtStep(
10159            -1,
10160            transitionName,
10161            fromDecision,
10162            whichFocus,
10163            inCommon
10164        )
10165
10166        # Replace with connection to existing destination
10167        destID = now.graph.resolveDecision(destination)
10168        if not self.hasBeenVisited(destID):
10169            raise ExplorationStatusError(
10170                f"Cannot return to decision"
10171                f" {now.graph.identityOf(destID)} because it has NOT"
10172                f" already been at least partially explored. Use"
10173                f" explore instead of returnTo when discovering a"
10174                f" connection to a previously-unexplored decision."
10175            )
10176
10177        now.graph.replaceUnconfirmed(
10178            fromID,
10179            transitionName,
10180            destID,
10181            reciprocal
10182        )
10183
10184        # A move-from-decision action
10185        actionTaken: base.ExplorationAction = (
10186            'take',
10187            using,
10188            fromID,
10189            (transitionName, outcomes)
10190        )
10191        if whichFocus is not None:
10192            # A move-from-specific-focal-point action
10193            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10194
10195        # Next, advance the situation, applying transition effects
10196        _, finalDest = self.advanceSituation(
10197            actionTaken,
10198            decisionType,
10199            challengePolicy
10200        )
10201
10202        assert len(finalDest) == 1
10203        return next(x for x in finalDest)
10204
10205    def takeAction(
10206        self,
10207        action: base.AnyTransition,
10208        requires: Optional[base.Requirement] = None,
10209        consequence: Optional[base.Consequence] = None,
10210        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10211        whichFocus: Optional[base.FocalPointSpecifier] = None,
10212        inCommon: Union[bool, Literal["auto"]] = "auto",
10213        decisionType: base.DecisionType = "active",
10214        challengePolicy: base.ChallengePolicy = "specified"
10215    ) -> base.DecisionID:
10216        """
10217        Adds a new graph to the exploration based on taking the given
10218        action, which must be a self-transition in the graph. If the
10219        action does not already exist in the graph, it will be created.
10220        Either way if requirements and/or a consequence are supplied,
10221        the requirements and consequence of the action will be updated
10222        to match them, and those are the requirements/consequence that
10223        will count.
10224
10225        Returns the decision ID for the decision reached, which normally
10226        is the same action you were just at, but which might be altered
10227        by goto, bounce, and/or follow effects.
10228
10229        Issues a `TransitionBlockedWarning` if the current game state
10230        doesn't satisfy the requirements for the action.
10231
10232        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
10233        used for `deduceTransitionDetailsAtStep`, while `decisionType`
10234        and `challengePolicy` are used for `advanceSituation`.
10235
10236        When an action is being created, `fromDecision` (or
10237        `whichFocus`) must be specified, since the source decision won't
10238        be deducible from the transition name. Note that if a transition
10239        with the given name exists from *any* active decision, it will
10240        be used instead of creating a new action (possibly resulting in
10241        an error if it's not a self-loop transition). Also, you may get
10242        an `AmbiguousTransitionError` if several transitions with that
10243        name exist; in that case use `fromDecision` and/or `whichFocus`
10244        to disambiguate.
10245        """
10246        now = self.getSituation()
10247        graph = now.graph
10248
10249        actionName, outcomes = base.nameAndOutcomes(action)
10250
10251        try:
10252            (
10253                using,
10254                fromID,
10255                destID,
10256                whichFocus
10257            ) = self.deduceTransitionDetailsAtStep(
10258                -1,
10259                actionName,
10260                fromDecision,
10261                whichFocus,
10262                inCommon
10263            )
10264
10265            if destID != fromID:
10266                raise ValueError(
10267                    f"Cannot take action {repr(action)} because it's a"
10268                    f" transition to another decision, not an action"
10269                    f" (use explore, returnTo, and/or retrace instead)."
10270                )
10271
10272        except MissingTransitionError:
10273            using = 'active'
10274            if inCommon is True:
10275                using = 'common'
10276
10277            if fromDecision is not None:
10278                fromID = graph.resolveDecision(fromDecision)
10279            elif whichFocus is not None:
10280                maybeFromID = base.resolvePosition(now, whichFocus)
10281                if maybeFromID is None:
10282                    raise MissingDecisionError(
10283                        f"Focal point {repr(whichFocus)} was specified"
10284                        f" in takeAction but that focal point doesn't"
10285                        f" have a position."
10286                    )
10287                else:
10288                    fromID = maybeFromID
10289            else:
10290                raise AmbiguousTransitionError(
10291                    f"Taking action {repr(action)} is ambiguous because"
10292                    f" the source decision has not been specified via"
10293                    f" either fromDecision or whichFocus, and we"
10294                    f" couldn't find an existing action with that name."
10295                )
10296
10297            # Since the action doesn't exist, add it:
10298            graph.addAction(fromID, actionName, requires, consequence)
10299
10300        # Update the transition requirement/consequence if requested
10301        # (before the action is taken)
10302        if requires is not None:
10303            graph.setTransitionRequirement(fromID, actionName, requires)
10304        if consequence is not None:
10305            graph.setConsequence(fromID, actionName, consequence)
10306
10307        # A move-from-decision action
10308        actionTaken: base.ExplorationAction = (
10309            'take',
10310            using,
10311            fromID,
10312            (actionName, outcomes)
10313        )
10314        if whichFocus is not None:
10315            # A move-from-specific-focal-point action
10316            actionTaken = ('take', whichFocus, (actionName, outcomes))
10317
10318        _, finalDest = self.advanceSituation(
10319            actionTaken,
10320            decisionType,
10321            challengePolicy
10322        )
10323
10324        assert len(finalDest) in (0, 1)
10325        if len(finalDest) == 1:
10326            return next(x for x in finalDest)
10327        else:
10328            return fromID
10329
10330    def retrace(
10331        self,
10332        transition: base.AnyTransition,
10333        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10334        whichFocus: Optional[base.FocalPointSpecifier] = None,
10335        inCommon: Union[bool, Literal["auto"]] = "auto",
10336        decisionType: base.DecisionType = "active",
10337        challengePolicy: base.ChallengePolicy = "specified"
10338    ) -> base.DecisionID:
10339        """
10340        Adds a new graph to the exploration based on taking the given
10341        transition, which must already exist and which must not lead to
10342        an unknown region. Returns the ID of the destination decision,
10343        accounting for goto, bounce, and/or follow effects.
10344
10345        Issues a `TransitionBlockedWarning` if the current game state
10346        doesn't satisfy the requirements for the transition.
10347
10348        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
10349        used for `deduceTransitionDetailsAtStep`, while `decisionType`
10350        and `challengePolicy` are used for `advanceSituation`.
10351        """
10352        now = self.getSituation()
10353
10354        transitionName, outcomes = base.nameAndOutcomes(transition)
10355
10356        (
10357            using,
10358            fromID,
10359            destID,
10360            whichFocus
10361        ) = self.deduceTransitionDetailsAtStep(
10362            -1,
10363            transitionName,
10364            fromDecision,
10365            whichFocus,
10366            inCommon
10367        )
10368
10369        visited = self.hasBeenVisited(destID)
10370        confirmed = now.graph.isConfirmed(destID)
10371        if not confirmed:
10372            raise ExplorationStatusError(
10373                f"Cannot retrace transition {transition!r} from"
10374                f" decision {now.graph.identityOf(fromID)} because it"
10375                f" leads to an unconfirmed decision.\nUse"
10376                f" `DiscreteExploration.explore` and provide"
10377                f" destination decision details instead."
10378            )
10379        if not visited:
10380            raise ExplorationStatusError(
10381                f"Cannot retrace transition {transition!r} from"
10382                f" decision {now.graph.identityOf(fromID)} because it"
10383                f" leads to an unvisited decision.\nUse"
10384                f" `DiscreteExploration.explore` and provide"
10385                f" destination decision details instead."
10386            )
10387
10388        # A move-from-decision action
10389        actionTaken: base.ExplorationAction = (
10390            'take',
10391            using,
10392            fromID,
10393            (transitionName, outcomes)
10394        )
10395        if whichFocus is not None:
10396            # A move-from-specific-focal-point action
10397            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10398
10399        _, finalDest = self.advanceSituation(
10400            actionTaken,
10401            decisionType,
10402        challengePolicy
10403    )
10404
10405        assert len(finalDest) == 1
10406        return next(x for x in finalDest)
10407
10408    def warp(
10409        self,
10410        destination: base.AnyDecisionSpecifier,
10411        consequence: Optional[base.Consequence] = None,
10412        domain: Optional[base.Domain] = None,
10413        zone: Optional[base.Zone] = base.DefaultZone,
10414        whichFocus: Optional[base.FocalPointSpecifier] = None,
10415        inCommon: Union[bool] = False,
10416        decisionType: base.DecisionType = "active",
10417        challengePolicy: base.ChallengePolicy = "specified"
10418    ) -> base.DecisionID:
10419        """
10420        Adds a new graph to the exploration that's a copy of the current
10421        graph, with the position updated to be at the destination without
10422        actually creating a transition from the old position to the new
10423        one. Returns the ID of the decision warped to (accounting for
10424        any goto or follow effects triggered).
10425
10426        Any provided consequences are applied, but are not associated
10427        with any transition (so any delays and charges are ignored, and
10428        'bounce' effects don't actually cancel the warp). 'goto' or
10429        'follow' effects might change the warp destination; 'follow'
10430        effects take the original destination as their starting point.
10431        Any mechanisms mentioned in extra consequences will be found
10432        based on the destination. Outcomes in supplied challenges should
10433        be pre-specified, or else they will be resolved with the
10434        `challengePolicy`.
10435
10436        `whichFocus` may be specified when the destination domain's
10437        focalization is 'plural' but for 'singular' or 'spreading'
10438        destination domains it is not allowed. `inCommon` determines
10439        whether the common or the active focal context is updated
10440        (default is to update the active context). The `decisionType`
10441        and `challengePolicy` are used for `advanceSituation`.
10442
10443        - If the destination did not already exist, it will be created.
10444            Initially, it will be disconnected from all other decisions.
10445            In this case, the `domain` value can be used to put it in a
10446            non-default domain.
10447        - The position is set to the specified destination, and if a
10448            `consequence` is specified it is applied. Note that
10449            'deactivate' effects are NOT allowed, and 'edit' effects
10450            must establish their own transition target because there is
10451            no transition that the effects are being applied to.
10452        - If the destination had been unexplored, its exploration status
10453            will be set to 'exploring'.
10454        - If a `zone` is specified, the destination will be added to that
10455            zone (even if the destination already existed) and that zone
10456            will be created (as a level-0 zone) if need be. If `zone` is
10457            set to `None`, then no zone will be applied. If `zone` is
10458            left as the default (`base.DefaultZone`) and the
10459            focalization of the destination domain is 'singular' or
10460            'plural' and the destination is newly created and there is
10461            an origin and the origin is in the same domain as the
10462            destination, then the destination will be added to all zones
10463            that the origin was a part of if the destination is newly
10464            created, but otherwise the destination will not be added to
10465            any zones. If the specified zone has to be created and
10466            there's an origin decision, it will be added as a sub-zone
10467            to all parents of zones directly containing the origin, as
10468            long as the origin is in the same domain as the destination.
10469        """
10470        now = self.getSituation()
10471        graph = now.graph
10472
10473        fromID: Optional[base.DecisionID]
10474
10475        new = False
10476        try:
10477            destID = graph.resolveDecision(destination)
10478        except MissingDecisionError:
10479            if isinstance(destination, tuple):
10480                # just the name; ignore zone/domain
10481                destination = destination[-1]
10482
10483            if not isinstance(destination, base.DecisionName):
10484                raise TypeError(
10485                    f"Warp destination {repr(destination)} does not"
10486                    f" exist, and cannot be created as it is not a"
10487                    f" decision name."
10488                )
10489            destID = graph.addDecision(destination, domain)
10490            graph.tagDecision(destID, 'unconfirmed')
10491            self.setExplorationStatus(destID, 'unknown')
10492            new = True
10493
10494        using: base.ContextSpecifier
10495        if inCommon:
10496            targetContext = self.getCommonContext()
10497            using = "common"
10498        else:
10499            targetContext = self.getActiveContext()
10500            using = "active"
10501
10502        destDomain = graph.domainFor(destID)
10503        targetFocalization = base.getDomainFocalization(
10504            targetContext,
10505            destDomain
10506        )
10507        if targetFocalization == 'singular':
10508            targetActive = targetContext['activeDecisions']
10509            if destDomain in targetActive:
10510                fromID = cast(
10511                    base.DecisionID,
10512                    targetContext['activeDecisions'][destDomain]
10513                )
10514            else:
10515                fromID = None
10516        elif targetFocalization == 'plural':
10517            if whichFocus is None:
10518                raise AmbiguousTransitionError(
10519                    f"Warping to {repr(destination)} is ambiguous"
10520                    f" becuase domain {repr(destDomain)} has plural"
10521                    f" focalization, and no whichFocus value was"
10522                    f" specified."
10523                )
10524
10525            fromID = base.resolvePosition(
10526                self.getSituation(),
10527                whichFocus
10528            )
10529        else:
10530            fromID = None
10531
10532        # Handle zones
10533        if zone == base.DefaultZone:
10534            if (
10535                new
10536            and fromID is not None
10537            and graph.domainFor(fromID) == destDomain
10538            ):
10539                for prevZone in graph.zoneParents(fromID):
10540                    graph.addDecisionToZone(destination, prevZone)
10541            # Otherwise don't update zones
10542        elif zone is not None:
10543            # Newness is ignored when a zone is specified
10544            zone = cast(base.Zone, zone)
10545            # Create the zone at level 0 if it didn't already exist
10546            if graph.getZoneInfo(zone) is None:
10547                graph.createZone(zone, 0)
10548                # Add the newly created zone to each 2nd-level parent of
10549                # the previous decision if there is one and it's in the
10550                # same domain
10551                if (
10552                    fromID is not None
10553                and graph.domainFor(fromID) == destDomain
10554                ):
10555                    for prevZone in graph.zoneParents(fromID):
10556                        for prevUpper in graph.zoneParents(prevZone):
10557                            graph.addZoneToZone(zone, prevUpper)
10558            # Finally add the destination to the (maybe new) zone
10559            graph.addDecisionToZone(destID, zone)
10560        # else don't touch zones
10561
10562        # Encode the action taken
10563        actionTaken: base.ExplorationAction
10564        if whichFocus is None:
10565            actionTaken = (
10566                'warp',
10567                using,
10568                destID
10569            )
10570        else:
10571            actionTaken = (
10572                'warp',
10573                whichFocus,
10574                destID
10575            )
10576
10577        # Advance the situation
10578        _, finalDests = self.advanceSituation(
10579            actionTaken,
10580            decisionType,
10581            challengePolicy
10582        )
10583        now = self.getSituation()  # updating just in case
10584
10585        assert len(finalDests) == 1
10586        finalDest = next(x for x in finalDests)
10587
10588        # Apply additional consequences:
10589        if consequence is not None:
10590            altDest = self.applyExtraneousConsequence(
10591                consequence,
10592                where=(destID, None),
10593                # TODO: Mechanism search from both ends?
10594                moveWhich=(
10595                    whichFocus[-1]
10596                    if whichFocus is not None
10597                    else None
10598                )
10599            )
10600            if altDest is not None:
10601                finalDest = altDest
10602            now = self.getSituation()  # updating just in case
10603
10604        return finalDest
10605
10606    def wait(
10607        self,
10608        consequence: Optional[base.Consequence] = None,
10609        decisionType: base.DecisionType = "active",
10610        challengePolicy: base.ChallengePolicy = "specified"
10611    ) -> Optional[base.DecisionID]:
10612        """
10613        Adds a wait step. If a consequence is specified, it is applied,
10614        although it will not have any position/transition information
10615        available during resolution/application.
10616
10617        A decision type other than "active" and/or a challenge policy
10618        other than "specified" can be included (see `advanceSituation`).
10619
10620        The "pending" decision type may not be used, a `ValueError` will
10621        result. This allows None as the action for waiting while
10622        preserving the pending/None type/action combination for
10623        unresolved situations.
10624
10625        If a goto or follow effect in the applied consequence implies a
10626        position update, this will return the new destination ID;
10627        otherwise it will return `None`. Triggering a 'bounce' effect
10628        will be an error, because there is no position information for
10629        the effect.
10630        """
10631        if decisionType == "pending":
10632            raise ValueError(
10633                "The 'pending' decision type may not be used for"
10634                " wait actions."
10635            )
10636        self.advanceSituation(('noAction',), decisionType, challengePolicy)
10637        now = self.getSituation()
10638        if consequence is not None:
10639            if challengePolicy != "specified":
10640                base.resetChallengeOutcomes(consequence)
10641            observed = base.observeChallengeOutcomes(
10642                base.RequirementContext(
10643                    state=now.state,
10644                    graph=now.graph,
10645                    searchFrom=set()
10646                ),
10647                consequence,
10648                location=None,  # No position info
10649                policy=challengePolicy,
10650                knownOutcomes=None  # bake outcomes into the consequence
10651            )
10652            # No location information since we might have multiple
10653            # active decisions and there's no indication of which one
10654            # we're "waiting at."
10655            finalDest = self.applyExtraneousConsequence(observed)
10656            now = self.getSituation()  # updating just in case
10657
10658            return finalDest
10659        else:
10660            return None
10661
10662    def revert(
10663        self,
10664        slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT,
10665        aspects: Optional[Set[str]] = None,
10666        decisionType: base.DecisionType = "active"
10667    ) -> None:
10668        """
10669        Reverts the game state to a previously-saved game state (saved
10670        via a 'save' effect). The save slot name and set of aspects to
10671        revert are required. By default, all aspects except the graph
10672        are reverted.
10673        """
10674        if aspects is None:
10675            aspects = set()
10676
10677        action: base.ExplorationAction = ("revertTo", slot, aspects)
10678
10679        self.advanceSituation(action, decisionType)
10680
10681    def observeAll(
10682        self,
10683        where: base.AnyDecisionSpecifier,
10684        *transitions: Union[
10685            base.Transition,
10686            Tuple[base.Transition, base.AnyDecisionSpecifier],
10687            Tuple[
10688                base.Transition,
10689                base.AnyDecisionSpecifier,
10690                base.Transition
10691            ]
10692        ]
10693    ) -> List[base.DecisionID]:
10694        """
10695        Observes one or more new transitions, applying changes to the
10696        current graph. The transitions can be specified in one of three
10697        ways:
10698
10699        1. A transition name. The transition will be created and will
10700            point to a new unexplored node.
10701        2. A pair containing a transition name and a destination
10702            specifier. If the destination does not exist it will be
10703            created as an unexplored node, although in that case the
10704            decision specifier may not be an ID.
10705        3. A triple containing a transition name, a destination
10706            specifier, and a reciprocal name. Works the same as the pair
10707            case but also specifies the name for the reciprocal
10708            transition.
10709
10710        The new transitions are outgoing from specified decision.
10711
10712        Yields the ID of each decision connected to, whether those are
10713        new or existing decisions.
10714        """
10715        now = self.getSituation()
10716        fromID = now.graph.resolveDecision(where)
10717        result = []
10718        for entry in transitions:
10719            if isinstance(entry, base.Transition):
10720                result.append(self.observe(fromID, entry))
10721            else:
10722                result.append(self.observe(fromID, *entry))
10723        return result
10724
10725    def observe(
10726        self,
10727        where: base.AnyDecisionSpecifier,
10728        transition: base.Transition,
10729        destination: Optional[base.AnyDecisionSpecifier] = None,
10730        reciprocal: Optional[base.Transition] = None
10731    ) -> base.DecisionID:
10732        """
10733        Observes a single new outgoing transition from the specified
10734        decision. If specified the transition connects to a specific
10735        destination and/or has a specific reciprocal. The specified
10736        destination will be created if it doesn't exist, or where no
10737        destination is specified, a new unexplored decision will be
10738        added. The ID of the decision connected to is returned.
10739
10740        Sets the exploration status of the observed destination to
10741        "noticed" if a destination is specified and needs to be created
10742        (but not when no destination is specified).
10743
10744        For example:
10745
10746        >>> e = DiscreteExploration()
10747        >>> e.start('start')
10748        0
10749        >>> e.observe('start', 'up')
10750        1
10751        >>> g = e.getSituation().graph
10752        >>> g.destinationsFrom('start')
10753        {'up': 1}
10754        >>> e.getExplorationStatus(1)  # not given a name: assumed unknown
10755        'unknown'
10756        >>> e.observe('start', 'left', 'A')
10757        2
10758        >>> g.destinationsFrom('start')
10759        {'up': 1, 'left': 2}
10760        >>> g.nameFor(2)
10761        'A'
10762        >>> e.getExplorationStatus(2)  # given a name: noticed
10763        'noticed'
10764        >>> e.observe('start', 'up2', 1)
10765        1
10766        >>> g.destinationsFrom('start')
10767        {'up': 1, 'left': 2, 'up2': 1}
10768        >>> e.getExplorationStatus(1)  # existing decision: status unchanged
10769        'unknown'
10770        >>> e.observe('start', 'right', 'B', 'left')
10771        3
10772        >>> g.destinationsFrom('start')
10773        {'up': 1, 'left': 2, 'up2': 1, 'right': 3}
10774        >>> g.nameFor(3)
10775        'B'
10776        >>> e.getExplorationStatus(3)  # new + name -> noticed
10777        'noticed'
10778        >>> e.observe('start', 'right')  # repeat transition name
10779        Traceback (most recent call last):
10780        ...
10781        exploration.core.TransitionCollisionError...
10782        >>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
10783        Traceback (most recent call last):
10784        ...
10785        exploration.core.TransitionCollisionError...
10786        >>> g = e.getSituation().graph
10787        >>> g.createZone('Z', 0)
10788        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
10789 annotations=[])
10790        >>> g.addDecisionToZone('start', 'Z')
10791        >>> e.observe('start', 'down', 'C', 'up')
10792        4
10793        >>> g.destinationsFrom('start')
10794        {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
10795        >>> g.identityOf('C')
10796        '4 (C)'
10797        >>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
10798        set()
10799        >>> e.observe(
10800        ...     'C',
10801        ...     'right',
10802        ...     base.DecisionSpecifier('main', 'Z2', 'D'),
10803        ... )  # creates zone
10804        5
10805        >>> g.destinationsFrom('C')
10806        {'up': 0, 'right': 5}
10807        >>> g.destinationsFrom('D')  # default reciprocal name
10808        {'return': 4}
10809        >>> g.identityOf('D')
10810        '5 (Z2::D)'
10811        >>> g.zoneParents(5)
10812        {'Z2'}
10813        """
10814        now = self.getSituation()
10815        fromID = now.graph.resolveDecision(where)
10816
10817        kwargs: Dict[
10818            str,
10819            Union[base.Transition, base.DecisionName, None]
10820        ] = {}
10821        if reciprocal is not None:
10822            kwargs['reciprocal'] = reciprocal
10823
10824        if destination is not None:
10825            try:
10826                destID = now.graph.resolveDecision(destination)
10827                now.graph.addTransition(
10828                    fromID,
10829                    transition,
10830                    destID,
10831                    reciprocal
10832                )
10833                return destID
10834            except MissingDecisionError:
10835                if isinstance(destination, base.DecisionSpecifier):
10836                    kwargs['toDomain'] = destination.domain
10837                    kwargs['placeInZone'] = destination.zone
10838                    kwargs['destinationName'] = destination.name
10839                elif isinstance(destination, base.DecisionName):
10840                    kwargs['destinationName'] = destination
10841                else:
10842                    assert isinstance(destination, base.DecisionID)
10843                    # We got to except by failing to resolve, so it's an
10844                    # invalid ID
10845                    raise
10846
10847        result = now.graph.addUnexploredEdge(
10848            fromID,
10849            transition,
10850            **kwargs  # type: ignore [arg-type]
10851        )
10852        if 'destinationName' in kwargs:
10853            self.setExplorationStatus(result, 'noticed', upgradeOnly=True)
10854        return result
10855
10856    def observeMechanisms(
10857        self,
10858        where: Optional[base.AnyDecisionSpecifier],
10859        *mechanisms: Union[
10860            base.MechanismName,
10861            Tuple[base.MechanismName, base.MechanismState]
10862        ]
10863    ) -> List[base.MechanismID]:
10864        """
10865        Adds one or more mechanisms to the exploration's current graph,
10866        located at the specified decision. Global mechanisms can be
10867        added by using `None` for the location. Mechanisms are named, or
10868        a (name, state) tuple can be used to set them into a specific
10869        state. Mechanisms not set to a state will be in the
10870        `base.DEFAULT_MECHANISM_STATE`.
10871        """
10872        now = self.getSituation()
10873        result = []
10874        for mSpec in mechanisms:
10875            setState = None
10876            if isinstance(mSpec, base.MechanismName):
10877                result.append(now.graph.addMechanism(mSpec, where))
10878            elif (
10879                isinstance(mSpec, tuple)
10880            and len(mSpec) == 2
10881            and isinstance(mSpec[0], base.MechanismName)
10882            and isinstance(mSpec[1], base.MechanismState)
10883            ):
10884                result.append(now.graph.addMechanism(mSpec[0], where))
10885                setState = mSpec[1]
10886            else:
10887                raise TypeError(
10888                    f"Invalid mechanism: {repr(mSpec)} (must be a"
10889                    f" mechanism name or a (name, state) tuple."
10890                )
10891
10892            if setState:
10893                self.setMechanismStateNow(result[-1], setState)
10894
10895        return result
10896
10897    def reZone(
10898        self,
10899        zone: base.Zone,
10900        where: base.AnyDecisionSpecifier,
10901        replace: Union[base.Zone, int] = 0
10902    ) -> None:
10903        """
10904        Alters the current graph without adding a new exploration step.
10905
10906        Calls `DecisionGraph.replaceZonesInHierarchy` targeting the
10907        specified decision. Note that per the logic of that method, ALL
10908        zones at the specified hierarchy level are replaced, even if a
10909        specific zone to replace is specified here.
10910
10911        TODO: not that?
10912
10913        The level value is either specified via `replace` (default 0) or
10914        deduced from the zone provided as the `replace` value using
10915        `DecisionGraph.zoneHierarchyLevel`.
10916        """
10917        now = self.getSituation()
10918
10919        if isinstance(replace, int):
10920            level = replace
10921        else:
10922            level = now.graph.zoneHierarchyLevel(replace)
10923
10924        now.graph.replaceZonesInHierarchy(where, zone, level)
10925
10926    def runCommand(
10927        self,
10928        command: commands.Command,
10929        scope: Optional[commands.Scope] = None,
10930        line: int = -1
10931    ) -> commands.CommandResult:
10932        """
10933        Runs a single `Command` applying effects to the exploration, its
10934        current graph, and the provided execution context, and returning
10935        a command result, which contains the modified scope plus
10936        optional skip and label values (see `CommandResult`). This
10937        function also directly modifies the scope you give it. Variable
10938        references in the command are resolved via entries in the
10939        provided scope. If no scope is given, an empty one is created.
10940
10941        A line number may be supplied for use in error messages; if left
10942        out line -1 will be used.
10943
10944        Raises an error if the command is invalid.
10945
10946        For commands that establish a value as the 'current value', that
10947        value will be stored in the '_' variable. When this happens, the
10948        old contents of '_' are stored in '__' first, and the old
10949        contents of '__' are discarded. Note that non-automatic
10950        assignment to '_' does not move the old value to '__'.
10951        """
10952        try:
10953            if scope is None:
10954                scope = {}
10955
10956            skip: Union[int, str, None] = None
10957            label: Optional[str] = None
10958
10959            if command.command == 'val':
10960                command = cast(commands.LiteralValue, command)
10961                result = commands.resolveValue(command.value, scope)
10962                commands.pushCurrentValue(scope, result)
10963
10964            elif command.command == 'empty':
10965                command = cast(commands.EstablishCollection, command)
10966                collection = commands.resolveVarName(command.collection, scope)
10967                commands.pushCurrentValue(
10968                    scope,
10969                    {
10970                        'list': [],
10971                        'tuple': (),
10972                        'set': set(),
10973                        'dict': {},
10974                    }[collection]
10975                )
10976
10977            elif command.command == 'append':
10978                command = cast(commands.AppendValue, command)
10979                target = scope['_']
10980                addIt = commands.resolveValue(command.value, scope)
10981                if isinstance(target, list):
10982                    target.append(addIt)
10983                elif isinstance(target, tuple):
10984                    scope['_'] = target + (addIt,)
10985                elif isinstance(target, set):
10986                    target.add(addIt)
10987                elif isinstance(target, dict):
10988                    raise TypeError(
10989                        "'append' command cannot be used with a"
10990                        " dictionary. Use 'set' instead."
10991                    )
10992                else:
10993                    raise TypeError(
10994                        f"Invalid current value for 'append' command."
10995                        f" The current value must be a list, tuple, or"
10996                        f" set, but it was a '{type(target).__name__}'."
10997                    )
10998
10999            elif command.command == 'set':
11000                command = cast(commands.SetValue, command)
11001                target = scope['_']
11002                where = commands.resolveValue(command.location, scope)
11003                what = commands.resolveValue(command.value, scope)
11004                if isinstance(target, list):
11005                    if not isinstance(where, int):
11006                        raise TypeError(
11007                            f"Cannot set item in list: index {where!r}"
11008                            f" is not an integer."
11009                        )
11010                    target[where] = what
11011                elif isinstance(target, tuple):
11012                    if not isinstance(where, int):
11013                        raise TypeError(
11014                            f"Cannot set item in tuple: index {where!r}"
11015                            f" is not an integer."
11016                        )
11017                    if not (
11018                        0 <= where < len(target)
11019                    or -1 >= where >= -len(target)
11020                    ):
11021                        raise IndexError(
11022                            f"Cannot set item in tuple at index"
11023                            f" {where}: Tuple has length {len(target)}."
11024                        )
11025                    scope['_'] = target[:where] + (what,) + target[where + 1:]
11026                elif isinstance(target, set):
11027                    if what:
11028                        target.add(where)
11029                    else:
11030                        try:
11031                            target.remove(where)
11032                        except KeyError:
11033                            pass
11034                elif isinstance(target, dict):
11035                    target[where] = what
11036
11037            elif command.command == 'pop':
11038                command = cast(commands.PopValue, command)
11039                target = scope['_']
11040                if isinstance(target, list):
11041                    result = target.pop()
11042                    commands.pushCurrentValue(scope, result)
11043                elif isinstance(target, tuple):
11044                    result = target[-1]
11045                    updated = target[:-1]
11046                    scope['__'] = updated
11047                    scope['_'] = result
11048                else:
11049                    raise TypeError(
11050                        f"Cannot 'pop' from a {type(target).__name__}"
11051                        f" (current value must be a list or tuple)."
11052                    )
11053
11054            elif command.command == 'get':
11055                command = cast(commands.GetValue, command)
11056                target = scope['_']
11057                where = commands.resolveValue(command.location, scope)
11058                if isinstance(target, list):
11059                    if not isinstance(where, int):
11060                        raise TypeError(
11061                            f"Cannot get item from list: index"
11062                            f" {where!r} is not an integer."
11063                        )
11064                elif isinstance(target, tuple):
11065                    if not isinstance(where, int):
11066                        raise TypeError(
11067                            f"Cannot get item from tuple: index"
11068                            f" {where!r} is not an integer."
11069                        )
11070                elif isinstance(target, set):
11071                    result = where in target
11072                    commands.pushCurrentValue(scope, result)
11073                elif isinstance(target, dict):
11074                    result = target[where]
11075                    commands.pushCurrentValue(scope, result)
11076                else:
11077                    result = getattr(target, where)
11078                    commands.pushCurrentValue(scope, result)
11079
11080            elif command.command == 'remove':
11081                command = cast(commands.RemoveValue, command)
11082                target = scope['_']
11083                where = commands.resolveValue(command.location, scope)
11084                if isinstance(target, (list, tuple)):
11085                    # this cast is not correct but suppresses warnings
11086                    # given insufficient narrowing by MyPy
11087                    target = cast(Tuple[Any, ...], target)
11088                    if not isinstance(where, int):
11089                        raise TypeError(
11090                            f"Cannot remove item from list or tuple:"
11091                            f" index {where!r} is not an integer."
11092                        )
11093                    scope['_'] = target[:where] + target[where + 1:]
11094                elif isinstance(target, set):
11095                    target.remove(where)
11096                elif isinstance(target, dict):
11097                    del target[where]
11098                else:
11099                    raise TypeError(
11100                        f"Cannot use 'remove' on a/an"
11101                        f" {type(target).__name__}."
11102                    )
11103
11104            elif command.command == 'op':
11105                command = cast(commands.ApplyOperator, command)
11106                left = commands.resolveValue(command.left, scope)
11107                right = commands.resolveValue(command.right, scope)
11108                op = command.op
11109                if op == '+':
11110                    result = left + right
11111                elif op == '-':
11112                    result = left - right
11113                elif op == '*':
11114                    result = left * right
11115                elif op == '/':
11116                    result = left / right
11117                elif op == '//':
11118                    result = left // right
11119                elif op == '**':
11120                    result = left ** right
11121                elif op == '%':
11122                    result = left % right
11123                elif op == '^':
11124                    result = left ^ right
11125                elif op == '|':
11126                    result = left | right
11127                elif op == '&':
11128                    result = left & right
11129                elif op == 'and':
11130                    result = left and right
11131                elif op == 'or':
11132                    result = left or right
11133                elif op == '<':
11134                    result = left < right
11135                elif op == '>':
11136                    result = left > right
11137                elif op == '<=':
11138                    result = left <= right
11139                elif op == '>=':
11140                    result = left >= right
11141                elif op == '==':
11142                    result = left == right
11143                elif op == 'is':
11144                    result = left is right
11145                else:
11146                    raise RuntimeError("Invalid operator '{op}'.")
11147
11148                commands.pushCurrentValue(scope, result)
11149
11150            elif command.command == 'unary':
11151                command = cast(commands.ApplyUnary, command)
11152                value = commands.resolveValue(command.value, scope)
11153                op = command.op
11154                if op == '-':
11155                    result = -value
11156                elif op == '~':
11157                    result = ~value
11158                elif op == 'not':
11159                    result = not value
11160
11161                commands.pushCurrentValue(scope, result)
11162
11163            elif command.command == 'assign':
11164                command = cast(commands.VariableAssignment, command)
11165                varname = commands.resolveVarName(command.varname, scope)
11166                value = commands.resolveValue(command.value, scope)
11167                scope[varname] = value
11168
11169            elif command.command == 'delete':
11170                command = cast(commands.VariableDeletion, command)
11171                varname = commands.resolveVarName(command.varname, scope)
11172                del scope[varname]
11173
11174            elif command.command == 'load':
11175                command = cast(commands.LoadVariable, command)
11176                varname = commands.resolveVarName(command.varname, scope)
11177                commands.pushCurrentValue(scope, scope[varname])
11178
11179            elif command.command == 'call':
11180                command = cast(commands.FunctionCall, command)
11181                function = command.function
11182                if function.startswith('$'):
11183                    function = commands.resolveValue(function, scope)
11184
11185                toCall: Callable
11186                args: Tuple[str, ...]
11187                kwargs: Dict[str, Any]
11188
11189                if command.target == 'builtin':
11190                    toCall = commands.COMMAND_BUILTINS[function]
11191                    args = (scope['_'],)
11192                    kwargs = {}
11193                    if toCall == round:
11194                        if 'ndigits' in scope:
11195                            kwargs['ndigits'] = scope['ndigits']
11196                    elif toCall == range and args[0] is None:
11197                        start = scope.get('start', 0)
11198                        stop = scope['stop']
11199                        step = scope.get('step', 1)
11200                        args = (start, stop, step)
11201
11202                else:
11203                    if command.target == 'stored':
11204                        toCall = function
11205                    elif command.target == 'graph':
11206                        toCall = getattr(self.getSituation().graph, function)
11207                    elif command.target == 'exploration':
11208                        toCall = getattr(self, function)
11209                    else:
11210                        raise TypeError(
11211                            f"Invalid call target '{command.target}'"
11212                            f" (must be one of 'builtin', 'stored',"
11213                            f" 'graph', or 'exploration'."
11214                        )
11215
11216                    # Fill in arguments via kwargs defined in scope
11217                    args = ()
11218                    kwargs = {}
11219                    signature = inspect.signature(toCall)
11220                    # TODO: Maybe try some type-checking here?
11221                    for argName, param in signature.parameters.items():
11222                        if param.kind == inspect.Parameter.VAR_POSITIONAL:
11223                            if argName in scope:
11224                                args = args + tuple(scope[argName])
11225                            # Else leave args as-is
11226                        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
11227                            # These must have a default
11228                            if argName in scope:
11229                                kwargs[argName] = scope[argName]
11230                        elif param.kind == inspect.Parameter.VAR_KEYWORD:
11231                            # treat as a dictionary
11232                            if argName in scope:
11233                                argsToUse = scope[argName]
11234                                if not isinstance(argsToUse, dict):
11235                                    raise TypeError(
11236                                        f"Variable '{argName}' must"
11237                                        f" hold a dictionary when"
11238                                        f" calling function"
11239                                        f" '{toCall.__name__} which"
11240                                        f" uses that argument as a"
11241                                        f" keyword catchall."
11242                                    )
11243                                kwargs.update(scope[argName])
11244                        else:  # a normal parameter
11245                            if argName in scope:
11246                                args = args + (scope[argName],)
11247                            elif param.default == inspect.Parameter.empty:
11248                                raise TypeError(
11249                                    f"No variable named '{argName}' has"
11250                                    f" been defined to supply the"
11251                                    f" required parameter with that"
11252                                    f" name for function"
11253                                    f" '{toCall.__name__}'."
11254                                )
11255
11256                result = toCall(*args, **kwargs)
11257                commands.pushCurrentValue(scope, result)
11258
11259            elif command.command == 'skip':
11260                command = cast(commands.SkipCommands, command)
11261                doIt = commands.resolveValue(command.condition, scope)
11262                if doIt:
11263                    skip = commands.resolveValue(command.amount, scope)
11264                    if not isinstance(skip, (int, str)):
11265                        raise TypeError(
11266                            f"Skip amount must be an integer or a label"
11267                            f" name (got {skip!r})."
11268                        )
11269
11270            elif command.command == 'label':
11271                command = cast(commands.Label, command)
11272                label = commands.resolveValue(command.name, scope)
11273                if not isinstance(label, str):
11274                    raise TypeError(
11275                        f"Label name must be a string (got {label!r})."
11276                    )
11277
11278            else:
11279                raise ValueError(
11280                    f"Invalid command type: {command.command!r}"
11281                )
11282        except ValueError as e:
11283            raise commands.CommandValueError(command, line, e)
11284        except TypeError as e:
11285            raise commands.CommandTypeError(command, line, e)
11286        except IndexError as e:
11287            raise commands.CommandIndexError(command, line, e)
11288        except KeyError as e:
11289            raise commands.CommandKeyError(command, line, e)
11290        except Exception as e:
11291            raise commands.CommandOtherError(command, line, e)
11292
11293        return (scope, skip, label)
11294
11295    def runCommandBlock(
11296        self,
11297        block: List[commands.Command],
11298        scope: Optional[commands.Scope] = None
11299    ) -> commands.Scope:
11300        """
11301        Runs a list of commands, using the given scope (or creating a new
11302        empty scope if none was provided). Returns the scope after
11303        running all of the commands, which may also edit the exploration
11304        and/or the current graph of course.
11305
11306        Note that if a skip command would skip past the end of the
11307        block, execution will end. If a skip command would skip before
11308        the beginning of the block, execution will start from the first
11309        command.
11310
11311        Example:
11312
11313        >>> e = DiscreteExploration()
11314        >>> scope = e.runCommandBlock([
11315        ...    commands.command('assign', 'decision', "'START'"),
11316        ...    commands.command('call', 'exploration', 'start'),
11317        ...    commands.command('assign', 'where', '$decision'),
11318        ...    commands.command('assign', 'transition', "'left'"),
11319        ...    commands.command('call', 'exploration', 'observe'),
11320        ...    commands.command('assign', 'transition', "'right'"),
11321        ...    commands.command('call', 'exploration', 'observe'),
11322        ...    commands.command('call', 'graph', 'destinationsFrom'),
11323        ...    commands.command('call', 'builtin', 'print'),
11324        ...    commands.command('assign', 'transition', "'right'"),
11325        ...    commands.command('assign', 'destination', "'EastRoom'"),
11326        ...    commands.command('call', 'exploration', 'explore'),
11327        ... ])
11328        {'left': 1, 'right': 2}
11329        >>> scope['decision']
11330        'START'
11331        >>> scope['where']
11332        'START'
11333        >>> scope['_']  # result of 'explore' call is dest ID
11334        2
11335        >>> scope['transition']
11336        'right'
11337        >>> scope['destination']
11338        'EastRoom'
11339        >>> g = e.getSituation().graph
11340        >>> len(e)
11341        3
11342        >>> len(g)
11343        3
11344        >>> g.namesListing(g)
11345        '  0 (START)\\n  1 (_u.0)\\n  2 (EastRoom)\\n'
11346        """
11347        if scope is None:
11348            scope = {}
11349
11350        labelPositions: Dict[str, List[int]] = {}
11351
11352        # Keep going until we've exhausted the commands list
11353        index = 0
11354        while index < len(block):
11355
11356            # Execute the next command
11357            scope, skip, label = self.runCommand(
11358                block[index],
11359                scope,
11360                index + 1
11361            )
11362
11363            # Increment our index, or apply a skip
11364            if skip is None:
11365                index = index + 1
11366
11367            elif isinstance(skip, int):  # Integer skip value
11368                if skip < 0:
11369                    index += skip
11370                    if index < 0:  # can't skip before the start
11371                        index = 0
11372                else:
11373                    index += skip + 1  # may end loop if we skip too far
11374
11375            else:  # must be a label name
11376                if skip in labelPositions:  # an established label
11377                    # We jump to the last previous index, or if there
11378                    # are none, to the first future index.
11379                    prevIndices = [
11380                        x
11381                        for x in labelPositions[skip]
11382                        if x < index
11383                    ]
11384                    futureIndices = [
11385                        x
11386                        for x in labelPositions[skip]
11387                        if x >= index
11388                    ]
11389                    if len(prevIndices) > 0:
11390                        index = max(prevIndices)
11391                    else:
11392                        index = min(futureIndices)
11393                else:  # must be a forward-reference
11394                    for future in range(index + 1, len(block)):
11395                        inspect = block[future]
11396                        if inspect.command == 'label':
11397                            inspect = cast(commands.Label, inspect)
11398                            if inspect.name == skip:
11399                                index = future
11400                                break
11401                    else:
11402                        raise KeyError(
11403                            f"Skip command indicated a jump to label"
11404                            f" {skip!r} but that label had not already"
11405                            f" been defined and there is no future"
11406                            f" label with that name either (future"
11407                            f" labels based on variables cannot be"
11408                            f" skipped to from above as their names"
11409                            f" are not known yet)."
11410                        )
11411
11412            # If there's a label, record it
11413            if label is not None:
11414                labelPositions.setdefault(label, []).append(index)
11415
11416            # And now the while loop continues, or ends if we're at the
11417            # end of the commands list.
11418
11419        # Return the scope object.
11420        return scope
11421
11422    @staticmethod
11423    def example() -> 'DiscreteExploration':
11424        """
11425        Returns a little example exploration. Has a few decisions
11426        including one that's unexplored, and uses a few steps to explore
11427        them.
11428
11429        >>> e = DiscreteExploration.example()
11430        >>> len(e)
11431        7
11432        >>> def pg(n):
11433        ...     print(e[n].graph.namesListing(e[n].graph))
11434        >>> pg(0)
11435          0 (House)
11436        <BLANKLINE>
11437        >>> pg(1)
11438          0 (House)
11439          1 (_u.0)
11440          2 (_u.1)
11441          3 (_u.2)
11442        <BLANKLINE>
11443        >>> pg(2)
11444          0 (House)
11445          1 (_u.0)
11446          2 (_u.1)
11447          3 (Yard)
11448          4 (_u.3)
11449          5 (_u.4)
11450        <BLANKLINE>
11451        >>> pg(3)
11452          0 (House)
11453          1 (_u.0)
11454          2 (_u.1)
11455          3 (Yard)
11456          4 (_u.3)
11457          5 (_u.4)
11458        <BLANKLINE>
11459        >>> pg(4)
11460          0 (House)
11461          1 (_u.0)
11462          2 (Cellar)
11463          3 (Yard)
11464          5 (_u.4)
11465        <BLANKLINE>
11466        >>> pg(5)
11467          0 (House)
11468          1 (_u.0)
11469          2 (Cellar)
11470          3 (Yard)
11471          5 (_u.4)
11472        <BLANKLINE>
11473        >>> pg(6)
11474          0 (House)
11475          1 (_u.0)
11476          2 (Cellar)
11477          3 (Yard)
11478          5 (Lane)
11479        <BLANKLINE>
11480        """
11481        result = DiscreteExploration()
11482        result.start("House")
11483        result.observeAll("House", "ladder", "stairsDown", "frontDoor")
11484        result.explore("frontDoor", "Yard", "frontDoor")
11485        result.observe("Yard", "cellarDoors")
11486        result.observe("Yard", "frontGate")
11487        result.retrace("frontDoor")
11488        result.explore("stairsDown", "Cellar", "stairsUp")
11489        result.observe("Cellar", "stairsOut")
11490        result.returnTo("stairsOut", "Yard", "cellarDoors")
11491        result.explore("frontGate", "Lane", "redGate")
11492        return result

A list of Situations each of which contains a DecisionGraph representing exploration over time, with States containing FocalContext information for each step and 'taken' values for the transition selected (at a particular decision) in that step. Each decision graph represents a new state of the world (and/or new knowledge about a persisting state of the world), and the 'taken' transition in one situation transition indicates which option was selected, or what event happened to cause update(s). Depending on the resolution, it could represent a close record of every decision made or a more coarse set of snapshots from gameplay with more time in between.

The steps of the exploration can also be tagged and annotated (see tagStep and annotateStep).

It also holds a layouts field that includes zero or more base.Layouts by name.

When a new DiscreteExploration is created, it starts out with an empty Situation that contains an empty DecisionGraph. Use the start method to name the starting decision point and set things up for other methods.

Tracking of player goals and destinations is also planned (see the quest, progress, complete, destination, and arrive methods). TODO: That

situations: List[exploration.base.Situation]
layouts: Dict[str, Dict[int, Tuple[float, float]]]
@staticmethod
def fromGraph( graph: DecisionGraph, state: Optional[exploration.base.State] = None) -> DiscreteExploration:
6617    @staticmethod
6618    def fromGraph(
6619        graph: DecisionGraph,
6620        state: Optional[base.State] = None
6621    ) -> 'DiscreteExploration':
6622        """
6623        Creates an exploration which has just a single step whose graph
6624        is the entire specified graph, with the specified decision as
6625        the primary decision (if any). The graph is copied, so that
6626        changes to the exploration will not modify it. A starting state
6627        may also be specified if desired, although if not an empty state
6628        will be used (a provided starting state is NOT copied, but used
6629        directly).
6630
6631        Example:
6632
6633        >>> g = DecisionGraph()
6634        >>> g.addDecision('Room1')
6635        0
6636        >>> g.addDecision('Room2')
6637        1
6638        >>> g.addTransition('Room1', 'door', 'Room2', 'door')
6639        >>> e = DiscreteExploration.fromGraph(g)
6640        >>> len(e)
6641        1
6642        >>> e.getSituation().graph == g
6643        True
6644        >>> e.getActiveDecisions()
6645        set()
6646        >>> e.primaryDecision() is None
6647        True
6648        >>> e.observe('Room1', 'hatch')
6649        2
6650        >>> e.getSituation().graph == g
6651        False
6652        >>> e.getSituation().graph.destinationsFrom('Room1')
6653        {'door': 1, 'hatch': 2}
6654        >>> g.destinationsFrom('Room1')
6655        {'door': 1}
6656        """
6657        result = DiscreteExploration()
6658        result.situations[0] = base.Situation(
6659            graph=copy.deepcopy(graph),
6660            state=base.emptyState() if state is None else state,
6661            type='pending',
6662            action=None,
6663            saves={},
6664            tags={},
6665            annotations=[]
6666        )
6667        return result

Creates an exploration which has just a single step whose graph is the entire specified graph, with the specified decision as the primary decision (if any). The graph is copied, so that changes to the exploration will not modify it. A starting state may also be specified if desired, although if not an empty state will be used (a provided starting state is NOT copied, but used directly).

Example:

>>> g = DecisionGraph()
>>> g.addDecision('Room1')
0
>>> g.addDecision('Room2')
1
>>> g.addTransition('Room1', 'door', 'Room2', 'door')
>>> e = DiscreteExploration.fromGraph(g)
>>> len(e)
1
>>> e.getSituation().graph == g
True
>>> e.getActiveDecisions()
set()
>>> e.primaryDecision() is None
True
>>> e.observe('Room1', 'hatch')
2
>>> e.getSituation().graph == g
False
>>> e.getSituation().graph.destinationsFrom('Room1')
{'door': 1, 'hatch': 2}
>>> g.destinationsFrom('Room1')
{'door': 1}
def getSituation(self, step: int = -1) -> exploration.base.Situation:
6688    def getSituation(self, step: int = -1) -> base.Situation:
6689        """
6690        Returns a `base.Situation` named tuple detailing the state of
6691        the exploration at a given step (or at the current step if no
6692        argument is given). Note that this method works the same
6693        way as indexing the exploration: see `__getitem__`.
6694
6695        Raises an `IndexError` if asked for a step that's out-of-range.
6696        """
6697        return self[step]

Returns a base.Situation named tuple detailing the state of the exploration at a given step (or at the current step if no argument is given). Note that this method works the same way as indexing the exploration: see __getitem__.

Raises an IndexError if asked for a step that's out-of-range.

def primaryDecision(self, step: int = -1) -> Optional[int]:
6699    def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]:
6700        """
6701        Returns the current primary `base.DecisionID`, or the primary
6702        decision from a specific step if one is specified. This may be
6703        `None` for some steps, but mostly it's the destination of the
6704        transition taken in the previous step.
6705        """
6706        return self[step].state['primaryDecision']

Returns the current primary base.DecisionID, or the primary decision from a specific step if one is specified. This may be None for some steps, but mostly it's the destination of the transition taken in the previous step.

def effectiveCapabilities(self, step: int = -1) -> exploration.base.CapabilitySet:
6708    def effectiveCapabilities(
6709        self,
6710        step: int = -1
6711    ) -> base.CapabilitySet:
6712        """
6713        Returns the effective capability set for the specified step
6714        (default is the last/current step). See
6715        `base.effectiveCapabilities`.
6716        """
6717        return base.effectiveCapabilitySet(self.getSituation(step).state)

Returns the effective capability set for the specified step (default is the last/current step). See base.effectiveCapabilities.

def getCommonContext(self, step: Optional[int] = None) -> exploration.base.FocalContext:
6719    def getCommonContext(
6720        self,
6721        step: Optional[int] = None
6722    ) -> base.FocalContext:
6723        """
6724        Returns the common `FocalContext` at the specified step, or at
6725        the current step if no argument is given. Raises an `IndexError`
6726        if an invalid step is specified.
6727        """
6728        if step is None:
6729            step = -1
6730        state = self.getSituation(step).state
6731        return state['common']

Returns the common FocalContext at the specified step, or at the current step if no argument is given. Raises an IndexError if an invalid step is specified.

def getActiveContext(self, step: Optional[int] = None) -> exploration.base.FocalContext:
6733    def getActiveContext(
6734        self,
6735        step: Optional[int] = None
6736    ) -> base.FocalContext:
6737        """
6738        Returns the active `FocalContext` at the specified step, or at
6739        the current step if no argument is provided. Raises an
6740        `IndexError` if an invalid step is specified.
6741        """
6742        if step is None:
6743            step = -1
6744        state = self.getSituation(step).state
6745        return state['contexts'][state['activeContext']]

Returns the active FocalContext at the specified step, or at the current step if no argument is provided. Raises an IndexError if an invalid step is specified.

def addFocalContext(self, name: str) -> None:
6747    def addFocalContext(self, name: base.FocalContextName) -> None:
6748        """
6749        Adds a new empty focal context to our set of focal contexts (see
6750        `emptyFocalContext`). Use `setActiveContext` to swap to it.
6751        Raises a `FocalContextCollisionError` if the name is already in
6752        use.
6753        """
6754        contextMap = self.getSituation().state['contexts']
6755        if name in contextMap:
6756            raise FocalContextCollisionError(
6757                f"Cannot add focal context {name!r}: a focal context"
6758                f" with that name already exists."
6759            )
6760        contextMap[name] = base.emptyFocalContext()

Adds a new empty focal context to our set of focal contexts (see emptyFocalContext). Use setActiveContext to swap to it. Raises a FocalContextCollisionError if the name is already in use.

def setActiveContext(self, which: str) -> None:
6762    def setActiveContext(self, which: base.FocalContextName) -> None:
6763        """
6764        Sets the active context to the named focal context, creating it
6765        if it did not already exist (makes changes to the current
6766        situation only). Does not add an exploration step (use
6767        `advanceSituation` with a 'swap' action for that).
6768        """
6769        state = self.getSituation().state
6770        contextMap = state['contexts']
6771        if which not in contextMap:
6772            self.addFocalContext(which)
6773        state['activeContext'] = which

Sets the active context to the named focal context, creating it if it did not already exist (makes changes to the current situation only). Does not add an exploration step (use advanceSituation with a 'swap' action for that).

def createDomain( self, name: str, focalization: Literal['singular', 'plural', 'spreading'] = 'singular', makeActive: bool = False, inCommon: Union[bool, Literal['both']] = 'both') -> None:
6775    def createDomain(
6776        self,
6777        name: base.Domain,
6778        focalization: base.DomainFocalization = 'singular',
6779        makeActive: bool = False,
6780        inCommon: Union[bool, Literal["both"]] = "both"
6781    ) -> None:
6782        """
6783        Creates a new domain with the given focalization type, in either
6784        the common context (`inCommon` = `True`) the active context
6785        (`inCommon` = `False`) or both (the default; `inCommon` = 'both').
6786        The domain's focalization will be set to the given
6787        `focalization` value (default 'singular') and it will have no
6788        active decisions. Raises a `DomainCollisionError` if a domain
6789        with the specified name already exists.
6790
6791        Creates the domain in the current situation.
6792
6793        If `makeActive` is set to `True` (default is `False`) then the
6794        domain will be made active in whichever context(s) it's created
6795        in.
6796        """
6797        now = self.getSituation()
6798        state = now.state
6799        modify = []
6800        if inCommon in (True, "both"):
6801            modify.append(('common', state['common']))
6802        if inCommon in (False, "both"):
6803            acName = state['activeContext']
6804            modify.append(
6805                ('current ({repr(acName)})', state['contexts'][acName])
6806            )
6807
6808        for (fcType, fc) in modify:
6809            if name in fc['focalization']:
6810                raise DomainCollisionError(
6811                    f"Cannot create domain {repr(name)} because a"
6812                    f" domain with that name already exists in the"
6813                    f" {fcType} focal context."
6814                )
6815            fc['focalization'][name] = focalization
6816            if makeActive:
6817                fc['activeDomains'].add(name)
6818            if focalization == "spreading":
6819                fc['activeDecisions'][name] = set()
6820            elif focalization == "plural":
6821                fc['activeDecisions'][name] = {}
6822            else:
6823                fc['activeDecisions'][name] = None

Creates a new domain with the given focalization type, in either the common context (inCommon = True) the active context (inCommon = False) or both (the default; inCommon = 'both'). The domain's focalization will be set to the given focalization value (default 'singular') and it will have no active decisions. Raises a DomainCollisionError if a domain with the specified name already exists.

Creates the domain in the current situation.

If makeActive is set to True (default is False) then the domain will be made active in whichever context(s) it's created in.

def activateDomain( self, domain: str, activate: bool = True, inContext: Literal['common', 'active'] = 'active') -> None:
6825    def activateDomain(
6826        self,
6827        domain: base.Domain,
6828        activate: bool = True,
6829        inContext: base.ContextSpecifier = "active"
6830    ) -> None:
6831        """
6832        Sets the given domain as active (or inactive if 'activate' is
6833        given as `False`) in the specified context (default "active").
6834
6835        Modifies the current situation.
6836        """
6837        fc: base.FocalContext
6838        if inContext == "active":
6839            fc = self.getActiveContext()
6840        elif inContext == "common":
6841            fc = self.getCommonContext()
6842
6843        if activate:
6844            fc['activeDomains'].add(domain)
6845        else:
6846            try:
6847                fc['activeDomains'].remove(domain)
6848            except KeyError:
6849                pass

Sets the given domain as active (or inactive if 'activate' is given as False) in the specified context (default "active").

Modifies the current situation.

def createTriggerGroup(self, name: str) -> int:
6851    def createTriggerGroup(
6852        self,
6853        name: base.DecisionName
6854    ) -> base.DecisionID:
6855        """
6856        Creates a new trigger group with the given name, returning the
6857        decision ID for that trigger group. If this is the first trigger
6858        group being created, also creates the `TRIGGERS_DOMAIN` domain
6859        as a spreading-focalized domain that's active in the common
6860        context (but does NOT set the created trigger group as an active
6861        decision in that domain).
6862
6863        You can use 'goto' effects to activate trigger domains via
6864        consequences, and 'retreat' effects to deactivate them.
6865
6866        Creating a second trigger group with the same name as another
6867        results in a `ValueError`.
6868
6869        TODO: Retreat effects
6870        """
6871        ctx = self.getCommonContext()
6872        if TRIGGERS_DOMAIN not in ctx['focalization']:
6873            self.createDomain(
6874                TRIGGERS_DOMAIN,
6875                focalization='spreading',
6876                makeActive=True,
6877                inCommon=True
6878            )
6879
6880        graph = self.getSituation().graph
6881        if graph.getDecision(
6882            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
6883        ) is not None:
6884            raise ValueError(
6885                f"Cannot create trigger group {name!r}: a trigger group"
6886                f" with that name already exists."
6887            )
6888
6889        return self.getSituation().graph.triggerGroupID(name)

Creates a new trigger group with the given name, returning the decision ID for that trigger group. If this is the first trigger group being created, also creates the TRIGGERS_DOMAIN domain as a spreading-focalized domain that's active in the common context (but does NOT set the created trigger group as an active decision in that domain).

You can use 'goto' effects to activate trigger domains via consequences, and 'retreat' effects to deactivate them.

Creating a second trigger group with the same name as another results in a ValueError.

TODO: Retreat effects

def toggleTriggerGroup(self, name: str, setActive: Optional[bool] = None):
6891    def toggleTriggerGroup(
6892        self,
6893        name: base.DecisionName,
6894        setActive: Union[bool, None] = None
6895    ):
6896        """
6897        Toggles whether the specified trigger group (a decision in the
6898        `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as
6899        the `setActive` argument (instead of the default `None`) to set
6900        the state directly instead of toggling it.
6901
6902        Note that trigger groups are decisions in a spreading-focalized
6903        domain, so they can be activated or deactivated by the 'goto'
6904        and 'retreat' effects as well.
6905
6906        This does not affect whether the `TRIGGERS_DOMAIN` itself is
6907        active (normally it would always be active).
6908
6909        Raises a `MissingDecisionError` if the specified trigger group
6910        does not exist yet, including when the entire `TRIGGERS_DOMAIN`
6911        does not exist. Raises a `KeyError` if the target group exists
6912        but the `TRIGGERS_DOMAIN` has not been set up properly.
6913        """
6914        ctx = self.getCommonContext()
6915        tID = self.getSituation().graph.resolveDecision(
6916            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
6917        )
6918        activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN]
6919        assert isinstance(activeGroups, set)
6920        if tID in activeGroups:
6921            if setActive is not True:
6922                activeGroups.remove(tID)
6923        else:
6924            if setActive is not False:
6925                activeGroups.add(tID)

Toggles whether the specified trigger group (a decision in the TRIGGERS_DOMAIN) is active or not. Pass True or False as the setActive argument (instead of the default None) to set the state directly instead of toggling it.

Note that trigger groups are decisions in a spreading-focalized domain, so they can be activated or deactivated by the 'goto' and 'retreat' effects as well.

This does not affect whether the TRIGGERS_DOMAIN itself is active (normally it would always be active).

Raises a MissingDecisionError if the specified trigger group does not exist yet, including when the entire TRIGGERS_DOMAIN does not exist. Raises a KeyError if the target group exists but the TRIGGERS_DOMAIN has not been set up properly.

def getActiveDecisions( self, step: Optional[int] = None, inCommon: Union[bool, Literal['both']] = 'both') -> Set[int]:
6927    def getActiveDecisions(
6928        self,
6929        step: Optional[int] = None,
6930        inCommon: Union[bool, Literal["both"]] = "both"
6931    ) -> Set[base.DecisionID]:
6932        """
6933        Returns the set of active decisions at the given step index, or
6934        at the current step if no step is specified. Raises an
6935        `IndexError` if the step index is out of bounds (see `__len__`).
6936        May return an empty set if no decisions are active.
6937
6938        If `inCommon` is set to "both" (the default) then decisions
6939        active in either the common or active context are returned. Set
6940        it to `True` or `False` to return only decisions active in the
6941        common (when `True`) or  active (when `False`) context.
6942        """
6943        if step is None:
6944            step = -1
6945        state = self.getSituation(step).state
6946        if inCommon == "both":
6947            return base.combinedDecisionSet(state)
6948        elif inCommon is True:
6949            return base.activeDecisionSet(state['common'])
6950        elif inCommon is False:
6951            return base.activeDecisionSet(
6952                state['contexts'][state['activeContext']]
6953            )
6954        else:
6955            raise ValueError(
6956                f"Invalid inCommon value {repr(inCommon)} (must be"
6957                f" 'both', True, or False)."
6958            )

Returns the set of active decisions at the given step index, or at the current step if no step is specified. Raises an IndexError if the step index is out of bounds (see __len__). May return an empty set if no decisions are active.

If inCommon is set to "both" (the default) then decisions active in either the common or active context are returned. Set it to True or False to return only decisions active in the common (when True) or active (when False) context.

def setActiveDecisionsAtStep( self, step: int, domain: str, activate: Union[int, Dict[str, Optional[int]], Set[int]], inCommon: bool = False) -> None:
6960    def setActiveDecisionsAtStep(
6961        self,
6962        step: int,
6963        domain: base.Domain,
6964        activate: Union[
6965            base.DecisionID,
6966            Dict[base.FocalPointName, Optional[base.DecisionID]],
6967            Set[base.DecisionID]
6968        ],
6969        inCommon: bool = False
6970    ) -> None:
6971        """
6972        Changes the activation status of decisions in the active
6973        `FocalContext` at the specified step, for the specified domain
6974        (see `currentActiveContext`). Does this without adding an
6975        exploration step, which is unusual: normally you should use
6976        another method like `warp` to update active decisions.
6977
6978        Note that this does not change which domains are active, and
6979        setting active decisions in inactive domains does not make those
6980        decisions active overall.
6981
6982        Which decisions to activate or deactivate are specified as
6983        either a single `DecisionID`, a list of them, or a set of them,
6984        depending on the `DomainFocalization` setting in the selected
6985        `FocalContext` for the specified domain. A `TypeError` will be
6986        raised if the wrong kind of decision information is provided. If
6987        the focalization context does not have any focalization value for
6988        the domain in question, it will be set based on the kind of
6989        active decision information specified.
6990
6991        A `MissingDecisionError` will be raised if a decision is
6992        included which is not part of the current `DecisionGraph`.
6993        The provided information will overwrite the previous active
6994        decision information.
6995
6996        If `inCommon` is set to `True`, then decisions are activated or
6997        deactivated in the common context, instead of in the active
6998        context.
6999
7000        Example:
7001
7002        >>> e = DiscreteExploration()
7003        >>> e.getActiveDecisions()
7004        set()
7005        >>> graph = e.getSituation().graph
7006        >>> graph.addDecision('A')
7007        0
7008        >>> graph.addDecision('B')
7009        1
7010        >>> graph.addDecision('C')
7011        2
7012        >>> e.setActiveDecisionsAtStep(0, 'main', 0)
7013        >>> e.getActiveDecisions()
7014        {0}
7015        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
7016        >>> e.getActiveDecisions()
7017        {1}
7018        >>> graph = e.getSituation().graph
7019        >>> graph.addDecision('One', domain='numbers')
7020        3
7021        >>> graph.addDecision('Two', domain='numbers')
7022        4
7023        >>> graph.addDecision('Three', domain='numbers')
7024        5
7025        >>> graph.addDecision('Bear', domain='animals')
7026        6
7027        >>> graph.addDecision('Spider', domain='animals')
7028        7
7029        >>> graph.addDecision('Eel', domain='animals')
7030        8
7031        >>> ac = e.getActiveContext()
7032        >>> ac['focalization']['numbers'] = 'plural'
7033        >>> ac['focalization']['animals'] = 'spreading'
7034        >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
7035        >>> ac['activeDecisions']['animals'] = set()
7036        >>> cc = e.getCommonContext()
7037        >>> cc['focalization']['numbers'] = 'plural'
7038        >>> cc['focalization']['animals'] = 'spreading'
7039        >>> cc['activeDecisions']['numbers'] = {'z': None}
7040        >>> cc['activeDecisions']['animals'] = set()
7041        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
7042        >>> e.getActiveDecisions()
7043        {1}
7044        >>> e.activateDomain('numbers')
7045        >>> e.getActiveDecisions()
7046        {1, 3}
7047        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
7048        >>> e.getActiveDecisions()
7049        {1, 4}
7050        >>> # Wrong domain for the decision ID:
7051        >>> e.setActiveDecisionsAtStep(0, 'main', 3)
7052        Traceback (most recent call last):
7053        ...
7054        ValueError...
7055        >>> # Wrong domain for one of the decision IDs:
7056        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
7057        Traceback (most recent call last):
7058        ...
7059        ValueError...
7060        >>> # Wrong kind of decision information provided.
7061        >>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
7062        Traceback (most recent call last):
7063        ...
7064        TypeError...
7065        >>> e.getActiveDecisions()
7066        {1, 4}
7067        >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
7068        >>> e.getActiveDecisions()
7069        {1, 4}
7070        >>> e.activateDomain('animals')
7071        >>> e.getActiveDecisions()
7072        {1, 4, 6, 7}
7073        >>> e.setActiveDecisionsAtStep(0, 'animals', {8})
7074        >>> e.getActiveDecisions()
7075        {8, 1, 4}
7076        >>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
7077        Traceback (most recent call last):
7078        ...
7079        IndexError...
7080        >>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
7081        Traceback (most recent call last):
7082        ...
7083        ValueError...
7084
7085        Example of active/common contexts:
7086
7087        >>> e = DiscreteExploration()
7088        >>> graph = e.getSituation().graph
7089        >>> graph.addDecision('A')
7090        0
7091        >>> graph.addDecision('B')
7092        1
7093        >>> e.activateDomain('main', inContext="common")
7094        >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
7095        >>> e.getActiveDecisions()
7096        {0}
7097        >>> e.setActiveDecisionsAtStep(0, 'main', None)
7098        >>> e.getActiveDecisions()
7099        {0}
7100        >>> # (Still active since it's active in the common context)
7101        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
7102        >>> e.getActiveDecisions()
7103        {0, 1}
7104        >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
7105        >>> e.getActiveDecisions()
7106        {1}
7107        >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
7108        >>> e.getActiveDecisions()
7109        {1}
7110        >>> # (Still active since it's active in the active context)
7111        >>> e.setActiveDecisionsAtStep(0, 'main', None)
7112        >>> e.getActiveDecisions()
7113        set()
7114        """
7115        now = self.getSituation(step)
7116        graph = now.graph
7117        if inCommon:
7118            context = self.getCommonContext(step)
7119        else:
7120            context = self.getActiveContext(step)
7121
7122        defaultFocalization: base.DomainFocalization = 'singular'
7123        if isinstance(activate, base.DecisionID):
7124            defaultFocalization = 'singular'
7125        elif isinstance(activate, dict):
7126            defaultFocalization = 'plural'
7127        elif isinstance(activate, set):
7128            defaultFocalization = 'spreading'
7129        elif domain not in context['focalization']:
7130            raise TypeError(
7131                f"Domain {domain!r} has no focalization in the"
7132                f" {'common' if inCommon else 'active'} context,"
7133                f" and the specified position doesn't imply one."
7134            )
7135
7136        focalization = base.getDomainFocalization(
7137            context,
7138            domain,
7139            defaultFocalization
7140        )
7141
7142        # Check domain & existence of decision(s) in question
7143        if activate is None:
7144            pass
7145        elif isinstance(activate, base.DecisionID):
7146            if activate not in graph:
7147                raise MissingDecisionError(
7148                    f"There is no decision {activate} at step {step}."
7149                )
7150            if graph.domainFor(activate) != domain:
7151                raise ValueError(
7152                    f"Can't set active decisions in domain {domain!r}"
7153                    f" to decision {graph.identityOf(activate)} because"
7154                    f" that decision is in actually in domain"
7155                    f" {graph.domainFor(activate)!r}."
7156                )
7157        elif isinstance(activate, dict):
7158            for fpName, pos in activate.items():
7159                if pos is None:
7160                    continue
7161                if pos not in graph:
7162                    raise MissingDecisionError(
7163                        f"There is no decision {pos} at step {step}."
7164                    )
7165                if graph.domainFor(pos) != domain:
7166                    raise ValueError(
7167                        f"Can't set active decision for focal point"
7168                        f" {fpName!r} in domain {domain!r}"
7169                        f" to decision {graph.identityOf(pos)} because"
7170                        f" that decision is in actually in domain"
7171                        f" {graph.domainFor(pos)!r}."
7172                    )
7173        elif isinstance(activate, set):
7174            for pos in activate:
7175                if pos not in graph:
7176                    raise MissingDecisionError(
7177                        f"There is no decision {pos} at step {step}."
7178                    )
7179                if graph.domainFor(pos) != domain:
7180                    raise ValueError(
7181                        f"Can't set {graph.identityOf(pos)} as an"
7182                        f" active decision in domain {domain!r} to"
7183                        f" decision because that decision is in"
7184                        f" actually in domain {graph.domainFor(pos)!r}."
7185                    )
7186        else:
7187            raise TypeError(
7188                f"Domain {domain!r} has no focalization in the"
7189                f" {'common' if inCommon else 'active'} context,"
7190                f" and the specified position doesn't imply one:"
7191                f"\n{activate!r}"
7192            )
7193
7194        if focalization == 'singular':
7195            if activate is None or isinstance(activate, base.DecisionID):
7196                if activate is not None:
7197                    targetDomain = graph.domainFor(activate)
7198                    if activate not in graph:
7199                        raise MissingDecisionError(
7200                            f"There is no decision {activate} in the"
7201                            f" graph at step {step}."
7202                        )
7203                    elif targetDomain != domain:
7204                        raise ValueError(
7205                            f"At step {step}, decision {activate} cannot"
7206                            f" be the active decision for domain"
7207                            f" {repr(domain)} because it is in a"
7208                            f" different domain ({repr(targetDomain)})."
7209                        )
7210                context['activeDecisions'][domain] = activate
7211            else:
7212                raise TypeError(
7213                    f"{'Common' if inCommon else 'Active'} focal"
7214                    f" context at step {step} has {repr(focalization)}"
7215                    f" focalization for domain {repr(domain)}, so the"
7216                    f" active decision must be a single decision or"
7217                    f" None.\n(You provided: {repr(activate)})"
7218                )
7219        elif focalization == 'plural':
7220            if (
7221                isinstance(activate, dict)
7222            and all(
7223                    isinstance(k, base.FocalPointName)
7224                    for k in activate.keys()
7225                )
7226            and all(
7227                    v is None or isinstance(v, base.DecisionID)
7228                    for v in activate.values()
7229                )
7230            ):
7231                for v in activate.values():
7232                    if v is not None:
7233                        targetDomain = graph.domainFor(v)
7234                        if v not in graph:
7235                            raise MissingDecisionError(
7236                                f"There is no decision {v} in the graph"
7237                                f" at step {step}."
7238                            )
7239                        elif targetDomain != domain:
7240                            raise ValueError(
7241                                f"At step {step}, decision {activate}"
7242                                f" cannot be an active decision for"
7243                                f" domain {repr(domain)} because it is"
7244                                f" in a different domain"
7245                                f" ({repr(targetDomain)})."
7246                            )
7247                context['activeDecisions'][domain] = activate
7248            else:
7249                raise TypeError(
7250                    f"{'Common' if inCommon else 'Active'} focal"
7251                    f" context at step {step} has {repr(focalization)}"
7252                    f" focalization for domain {repr(domain)}, so the"
7253                    f" active decision must be a dictionary mapping"
7254                    f" focal point names to decision IDs (or Nones)."
7255                    f"\n(You provided: {repr(activate)})"
7256                )
7257        elif focalization == 'spreading':
7258            if (
7259                isinstance(activate, set)
7260            and all(isinstance(x, base.DecisionID) for x in activate)
7261            ):
7262                for x in activate:
7263                    targetDomain = graph.domainFor(x)
7264                    if x not in graph:
7265                        raise MissingDecisionError(
7266                            f"There is no decision {x} in the graph"
7267                            f" at step {step}."
7268                        )
7269                    elif targetDomain != domain:
7270                        raise ValueError(
7271                            f"At step {step}, decision {activate}"
7272                            f" cannot be an active decision for"
7273                            f" domain {repr(domain)} because it is"
7274                            f" in a different domain"
7275                            f" ({repr(targetDomain)})."
7276                        )
7277                context['activeDecisions'][domain] = activate
7278            else:
7279                raise TypeError(
7280                    f"{'Common' if inCommon else 'Active'} focal"
7281                    f" context at step {step} has {repr(focalization)}"
7282                    f" focalization for domain {repr(domain)}, so the"
7283                    f" active decision must be a set of decision IDs"
7284                    f"\n(You provided: {repr(activate)})"
7285                )
7286        else:
7287            raise RuntimeError(
7288                f"Invalid focalization value {repr(focalization)} for"
7289                f" domain {repr(domain)} at step {step}."
7290            )

Changes the activation status of decisions in the active FocalContext at the specified step, for the specified domain (see currentActiveContext). Does this without adding an exploration step, which is unusual: normally you should use another method like warp to update active decisions.

Note that this does not change which domains are active, and setting active decisions in inactive domains does not make those decisions active overall.

Which decisions to activate or deactivate are specified as either a single DecisionID, a list of them, or a set of them, depending on the DomainFocalization setting in the selected FocalContext for the specified domain. A TypeError will be raised if the wrong kind of decision information is provided. If the focalization context does not have any focalization value for the domain in question, it will be set based on the kind of active decision information specified.

A MissingDecisionError will be raised if a decision is included which is not part of the current DecisionGraph. The provided information will overwrite the previous active decision information.

If inCommon is set to True, then decisions are activated or deactivated in the common context, instead of in the active context.

Example:

>>> e = DiscreteExploration()
>>> e.getActiveDecisions()
set()
>>> graph = e.getSituation().graph
>>> graph.addDecision('A')
0
>>> graph.addDecision('B')
1
>>> graph.addDecision('C')
2
>>> e.setActiveDecisionsAtStep(0, 'main', 0)
>>> e.getActiveDecisions()
{0}
>>> e.setActiveDecisionsAtStep(0, 'main', 1)
>>> e.getActiveDecisions()
{1}
>>> graph = e.getSituation().graph
>>> graph.addDecision('One', domain='numbers')
3
>>> graph.addDecision('Two', domain='numbers')
4
>>> graph.addDecision('Three', domain='numbers')
5
>>> graph.addDecision('Bear', domain='animals')
6
>>> graph.addDecision('Spider', domain='animals')
7
>>> graph.addDecision('Eel', domain='animals')
8
>>> ac = e.getActiveContext()
>>> ac['focalization']['numbers'] = 'plural'
>>> ac['focalization']['animals'] = 'spreading'
>>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
>>> ac['activeDecisions']['animals'] = set()
>>> cc = e.getCommonContext()
>>> cc['focalization']['numbers'] = 'plural'
>>> cc['focalization']['animals'] = 'spreading'
>>> cc['activeDecisions']['numbers'] = {'z': None}
>>> cc['activeDecisions']['animals'] = set()
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
>>> e.getActiveDecisions()
{1}
>>> e.activateDomain('numbers')
>>> e.getActiveDecisions()
{1, 3}
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
>>> e.getActiveDecisions()
{1, 4}
>>> # Wrong domain for the decision ID:
>>> e.setActiveDecisionsAtStep(0, 'main', 3)
Traceback (most recent call last):
...
ValueError...
>>> # Wrong domain for one of the decision IDs:
>>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
Traceback (most recent call last):
...
ValueError...
>>> # Wrong kind of decision information provided.
>>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
Traceback (most recent call last):
...
TypeError...
>>> e.getActiveDecisions()
{1, 4}
>>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
>>> e.getActiveDecisions()
{1, 4}
>>> e.activateDomain('animals')
>>> e.getActiveDecisions()
{1, 4, 6, 7}
>>> e.setActiveDecisionsAtStep(0, 'animals', {8})
>>> e.getActiveDecisions()
{8, 1, 4}
>>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
Traceback (most recent call last):
...
IndexError...
>>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
Traceback (most recent call last):
...
ValueError...

Example of active/common contexts:

>>> e = DiscreteExploration()
>>> graph = e.getSituation().graph
>>> graph.addDecision('A')
0
>>> graph.addDecision('B')
1
>>> e.activateDomain('main', inContext="common")
>>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
>>> e.getActiveDecisions()
{0}
>>> e.setActiveDecisionsAtStep(0, 'main', None)
>>> e.getActiveDecisions()
{0}
>>> # (Still active since it's active in the common context)
>>> e.setActiveDecisionsAtStep(0, 'main', 1)
>>> e.getActiveDecisions()
{0, 1}
>>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
>>> e.getActiveDecisions()
{1}
>>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
>>> e.getActiveDecisions()
{1}
>>> # (Still active since it's active in the active context)
>>> e.setActiveDecisionsAtStep(0, 'main', None)
>>> e.getActiveDecisions()
set()
def movementAtStep( self, step: int = -1) -> Tuple[Union[int, Set[int], NoneType], Optional[str], Union[int, Set[int], NoneType]]:
7292    def movementAtStep(self, step: int = -1) -> Tuple[
7293        Union[base.DecisionID, Set[base.DecisionID], None],
7294        Optional[base.Transition],
7295        Union[base.DecisionID, Set[base.DecisionID], None]
7296    ]:
7297        """
7298        Given a step number, returns information about the starting
7299        decision, transition taken, and destination decision for that
7300        step. Not all steps have all of those, so some items may be
7301        `None`.
7302
7303        For steps where there is no action, where a decision is still
7304        pending, or where the action type is 'focus', 'swap', 'focalize',
7305        or 'revertTo', the result will be `(None, None, None)`, unless a
7306        primary decision is available in which case the first item in the
7307        tuple will be that decision. For 'start' actions, the starting
7308        position and transition will be `None` (again unless the step had
7309        a primary decision) but the destination will be the ID of the
7310        node started at. For 'revertTo' actions, the destination will be
7311        the primary decision of the state reverted to, if available.
7312
7313        Also, if the action taken has multiple potential or actual start
7314        or end points, these may be sets of decision IDs instead of
7315        single IDs.
7316
7317        Note that the primary decision of the starting state is usually
7318        used as the from-decision, but in some cases an action dictates
7319        taking a transition from a different decision, and this function
7320        will return that decision as the from-decision.
7321
7322        TODO: Examples!
7323
7324        TODO: Account for bounce/follow/goto effects!!!
7325        """
7326        now = self.getSituation(step)
7327        action = now.action
7328        graph = now.graph
7329        primary = now.state['primaryDecision']
7330
7331        if action is None:
7332            return (primary, None, None)
7333
7334        aType = action[0]
7335        fromID: Optional[base.DecisionID]
7336        destID: Optional[base.DecisionID]
7337        transition: base.Transition
7338        outcomes: List[bool]
7339
7340        if aType in ('noAction', 'focus', 'swap', 'focalize'):
7341            return (primary, None, None)
7342        elif aType == 'start':
7343            assert len(action) == 7
7344            where = cast(
7345                Union[
7346                    base.DecisionID,
7347                    Dict[base.FocalPointName, base.DecisionID],
7348                    Set[base.DecisionID]
7349                ],
7350                action[1]
7351            )
7352            if isinstance(where, dict):
7353                where = set(where.values())
7354            return (primary, None, where)
7355        elif aType in ('take', 'explore'):
7356            if (
7357                (len(action) == 4 or len(action) == 7)
7358            and isinstance(action[2], base.DecisionID)
7359            ):
7360                fromID = action[2]
7361                assert isinstance(action[3], tuple)
7362                transition, outcomes = action[3]
7363                if (
7364                    action[0] == "explore"
7365                and isinstance(action[4], base.DecisionID)
7366                ):
7367                    destID = action[4]
7368                else:
7369                    destID = graph.getDestination(fromID, transition)
7370                return (fromID, transition, destID)
7371            elif (
7372                (len(action) == 3 or len(action) == 6)
7373            and isinstance(action[1], tuple)
7374            and isinstance(action[2], base.Transition)
7375            and len(action[1]) == 3
7376            and action[1][0] in get_args(base.ContextSpecifier)
7377            and isinstance(action[1][1], base.Domain)
7378            and isinstance(action[1][2], base.FocalPointName)
7379            ):
7380                fromID = base.resolvePosition(now, action[1])
7381                if fromID is None:
7382                    raise InvalidActionError(
7383                        f"{aType!r} action at step {step} has position"
7384                        f" {action[1]!r} which cannot be resolved to a"
7385                        f" decision."
7386                    )
7387                transition, outcomes = action[2]
7388                if (
7389                    action[0] == "explore"
7390                and isinstance(action[3], base.DecisionID)
7391                ):
7392                    destID = action[3]
7393                else:
7394                    destID = graph.getDestination(fromID, transition)
7395                return (fromID, transition, destID)
7396            else:
7397                raise InvalidActionError(
7398                    f"Malformed {aType!r} action:\n{repr(action)}"
7399                )
7400        elif aType == 'warp':
7401            if len(action) != 3:
7402                raise InvalidActionError(
7403                    f"Malformed 'warp' action:\n{repr(action)}"
7404                )
7405            dest = action[2]
7406            assert isinstance(dest, base.DecisionID)
7407            if action[1] in get_args(base.ContextSpecifier):
7408                # Unspecified starting point; find active decisions in
7409                # same domain if primary is None
7410                if primary is not None:
7411                    return (primary, None, dest)
7412                else:
7413                    toDomain = now.graph.domainFor(dest)
7414                    # TODO: Could check destination focalization here...
7415                    active = self.getActiveDecisions(step)
7416                    sameDomain = set(
7417                        dID
7418                        for dID in active
7419                        if now.graph.domainFor(dID) == toDomain
7420                    )
7421                    if len(sameDomain) == 1:
7422                        return (
7423                            list(sameDomain)[0],
7424                            None,
7425                            dest
7426                        )
7427                    else:
7428                        return (
7429                            sameDomain,
7430                            None,
7431                            dest
7432                        )
7433            else:
7434                if (
7435                    not isinstance(action[1], tuple)
7436                or not len(action[1]) == 3
7437                or not action[1][0] in get_args(base.ContextSpecifier)
7438                or not isinstance(action[1][1], base.Domain)
7439                or not isinstance(action[1][2], base.FocalPointName)
7440                ):
7441                    raise InvalidActionError(
7442                        f"Malformed 'warp' action:\n{repr(action)}"
7443                    )
7444                return (
7445                    base.resolvePosition(now, action[1]),
7446                    None,
7447                    dest
7448                )
7449        elif aType == 'revertTo':
7450            assert len(action) == 3  # type, save slot, & aspects
7451            if primary is not None:
7452                cameFrom = primary
7453            nextSituation = self.getSituation(step + 1)
7454            wentTo = nextSituation.state['primaryDecision']
7455            return (primary, None, wentTo)
7456        else:
7457            raise InvalidActionError(
7458                f"Action taken had invalid action type {repr(aType)}:"
7459                f"\n{repr(action)}"
7460            )

Given a step number, returns information about the starting decision, transition taken, and destination decision for that step. Not all steps have all of those, so some items may be None.

For steps where there is no action, where a decision is still pending, or where the action type is 'focus', 'swap', 'focalize', or 'revertTo', the result will be (None, None, None), unless a primary decision is available in which case the first item in the tuple will be that decision. For 'start' actions, the starting position and transition will be None (again unless the step had a primary decision) but the destination will be the ID of the node started at. For 'revertTo' actions, the destination will be the primary decision of the state reverted to, if available.

Also, if the action taken has multiple potential or actual start or end points, these may be sets of decision IDs instead of single IDs.

Note that the primary decision of the starting state is usually used as the from-decision, but in some cases an action dictates taking a transition from a different decision, and this function will return that decision as the from-decision.

TODO: Examples!

TODO: Account for bounce/follow/goto effects!!!

def latestStepWithDecision(self, dID: int, startFrom: int = -1) -> int:
7462    def latestStepWithDecision(
7463        self,
7464        dID: base.DecisionID,
7465        startFrom: int = -1
7466    ) -> int:
7467        """
7468        Scans backwards through exploration steps until it finds a graph
7469        that contains a decision with the specified ID, and returns the
7470        step number of that step. Instead of starting from the last step,
7471        you can tell it to start from a different step (either positive
7472        or negative index) via `startFrom`. Raises a
7473        `MissingDecisionError` if there is no such step.
7474        """
7475        if startFrom < 0:
7476            startFrom = len(self) + startFrom
7477        for step in range(startFrom, -1, -1):
7478            graph = self.getSituation(step).graph
7479            try:
7480                return step
7481            except MissingDecisionError:
7482                continue
7483        raise MissingDecisionError(
7484            f"Decision {dID!r} does not exist at any step of the"
7485            f" exploration."
7486        )

Scans backwards through exploration steps until it finds a graph that contains a decision with the specified ID, and returns the step number of that step. Instead of starting from the last step, you can tell it to start from a different step (either positive or negative index) via startFrom. Raises a MissingDecisionError if there is no such step.

def latestDecisionInfo(self, dID: int) -> DecisionInfo:
7488    def latestDecisionInfo(self, dID: base.DecisionID) -> DecisionInfo:
7489        """
7490        Looks up decision info for the given decision in the latest step
7491        in which that decision exists (which will usually be the final
7492        exploration step, unless the decision was merged or otherwise
7493        removed along the way). This will raise a `MissingDecisionError`
7494        only if there is no step at which the specified decision exists.
7495        """
7496        for step in range(len(self) - 1, -1, -1):
7497            graph = self.getSituation(step).graph
7498            try:
7499                return graph.decisionInfo(dID)
7500            except MissingDecisionError:
7501                continue
7502        raise MissingDecisionError(
7503            f"Decision {dID!r} does not exist at any step of the"
7504            f" exploration."
7505        )

Looks up decision info for the given decision in the latest step in which that decision exists (which will usually be the final exploration step, unless the decision was merged or otherwise removed along the way). This will raise a MissingDecisionError only if there is no step at which the specified decision exists.

def latestTransitionProperties(self, dID: int, transition: str) -> TransitionProperties:
7507    def latestTransitionProperties(
7508        self,
7509        dID: base.DecisionID,
7510        transition: base.Transition
7511    ) -> TransitionProperties:
7512        """
7513        Looks up transition properties for the transition with the given
7514        name outgoing from the decision with the given ID, in the latest
7515        step in which a transiiton with that name from that decision
7516        exists (which will usually be the final exploration step, unless
7517        transitions get removed/renamed along the way). Note that because
7518        a transition can be deleted and later added back (unlike
7519        decisions where an ID will not be re-used), it's possible there
7520        are two or more different transitions that meet the
7521        specifications at different points in time, and this will always
7522        return the properties of the last of them. This will raise a
7523        `MissingDecisionError` if there is no step at which the specified
7524        decision exists, and a `MissingTransitionError` if the target
7525        decision exists at some step but never has a transition with the
7526        specified name.
7527        """
7528        sawDecision: Optional[int] = None
7529        for step in range(len(self) - 1, -1, -1):
7530            graph = self.getSituation(step).graph
7531            try:
7532                return graph.getTransitionProperties(dID, transition)
7533            except (MissingDecisionError, MissingTransitionError) as e:
7534                if (
7535                    sawDecision is None
7536                and isinstance(e, MissingTransitionError)
7537                ):
7538                    sawDecision = step
7539                continue
7540        if sawDecision is None:
7541            raise MissingDecisionError(
7542                f"Decision {dID!r} does not exist at any step of the"
7543                f" exploration."
7544            )
7545        else:
7546            raise MissingTransitionError(
7547                f"Decision {dID!r} does exist (last seen at step"
7548                f" {sawDecision}) but it never has an outgoing"
7549                f" transition named {transition!r}."
7550            )

Looks up transition properties for the transition with the given name outgoing from the decision with the given ID, in the latest step in which a transiiton with that name from that decision exists (which will usually be the final exploration step, unless transitions get removed/renamed along the way). Note that because a transition can be deleted and later added back (unlike decisions where an ID will not be re-used), it's possible there are two or more different transitions that meet the specifications at different points in time, and this will always return the properties of the last of them. This will raise a MissingDecisionError if there is no step at which the specified decision exists, and a MissingTransitionError if the target decision exists at some step but never has a transition with the specified name.

def tagStep( self, tagOrTags: Union[str, Dict[str, Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]]]], tagValue: Union[bool, int, float, str, list, dict, NoneType, exploration.base.Requirement, List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], type[exploration.base.NoTagValue]] = <class 'exploration.base.NoTagValue'>, step: int = -1) -> None:
7552    def tagStep(
7553        self,
7554        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
7555        tagValue: Union[
7556            base.TagValue,
7557            type[base.NoTagValue]
7558        ] = base.NoTagValue,
7559        step: int = -1
7560    ) -> None:
7561        """
7562        Adds a tag (or multiple tags) to the current step, or to a
7563        specific step if `n` is given as an integer rather than the
7564        default `None`. A tag value should be supplied when a tag is
7565        given (unless you want to use the default of `1`), but it's a
7566        `ValueError` to supply a tag value when a dictionary of tags to
7567        update is provided.
7568        """
7569        if isinstance(tagOrTags, base.Tag):
7570            if tagValue is base.NoTagValue:
7571                tagValue = 1
7572
7573            # Not sure why this is necessary...
7574            tagValue = cast(base.TagValue, tagValue)
7575
7576            self.getSituation(step).tags.update({tagOrTags: tagValue})
7577        else:
7578            self.getSituation(step).tags.update(tagOrTags)

Adds a tag (or multiple tags) to the current step, or to a specific step if n is given as an integer rather than the default None. A tag value should be supplied when a tag is given (unless you want to use the default of 1), but it's a ValueError to supply a tag value when a dictionary of tags to update is provided.

def annotateStep( self, annotationOrAnnotations: Union[str, Sequence[str]], step: Optional[int] = None) -> None:
7580    def annotateStep(
7581        self,
7582        annotationOrAnnotations: Union[
7583            base.Annotation,
7584            Sequence[base.Annotation]
7585        ],
7586        step: Optional[int] = None
7587    ) -> None:
7588        """
7589        Adds an annotation to the current exploration step, or to a
7590        specific step if `n` is given as an integer rather than the
7591        default `None`.
7592        """
7593        if step is None:
7594            step = -1
7595        if isinstance(annotationOrAnnotations, base.Annotation):
7596            self.getSituation(step).annotations.append(
7597                annotationOrAnnotations
7598            )
7599        else:
7600            self.getSituation(step).annotations.extend(
7601                annotationOrAnnotations
7602            )

Adds an annotation to the current exploration step, or to a specific step if n is given as an integer rather than the default None.

def hasCapability( self, capability: str, step: Optional[int] = None, inCommon: Union[bool, Literal['both']] = 'both') -> bool:
7604    def hasCapability(
7605        self,
7606        capability: base.Capability,
7607        step: Optional[int] = None,
7608        inCommon: Union[bool, Literal['both']] = "both"
7609    ) -> bool:
7610        """
7611        Returns True if the player currently had the specified
7612        capability, at the specified exploration step, and False
7613        otherwise. Checks the current state if no step is given. Does
7614        NOT return true if the game state means that the player has an
7615        equivalent for that capability (see
7616        `hasCapabilityOrEquivalent`).
7617
7618        Normally, `inCommon` is set to 'both' by default and so if
7619        either the common `FocalContext` or the active one has the
7620        capability, this will return `True`. `inCommon` may instead be
7621        set to `True` or `False` to ask about just the common (or
7622        active) focal context.
7623        """
7624        state = self.getSituation().state
7625        commonCapabilities = state['common']['capabilities']\
7626            ['capabilities']  # noqa
7627        activeCapabilities = state['contexts'][state['activeContext']]\
7628            ['capabilities']['capabilities']  # noqa
7629
7630        if inCommon == 'both':
7631            return (
7632                capability in commonCapabilities
7633             or capability in activeCapabilities
7634            )
7635        elif inCommon is True:
7636            return capability in commonCapabilities
7637        elif inCommon is False:
7638            return capability in activeCapabilities
7639        else:
7640            raise ValueError(
7641                f"Invalid inCommon value (must be False, True, or"
7642                f" 'both'; got {repr(inCommon)})."
7643            )

Returns True if the player currently had the specified capability, at the specified exploration step, and False otherwise. Checks the current state if no step is given. Does NOT return true if the game state means that the player has an equivalent for that capability (see hasCapabilityOrEquivalent).

Normally, inCommon is set to 'both' by default and so if either the common FocalContext or the active one has the capability, this will return True. inCommon may instead be set to True or False to ask about just the common (or active) focal context.

def hasCapabilityOrEquivalent( self, capability: str, step: Optional[int] = None, location: Optional[Set[int]] = None) -> bool:
7645    def hasCapabilityOrEquivalent(
7646        self,
7647        capability: base.Capability,
7648        step: Optional[int] = None,
7649        location: Optional[Set[base.DecisionID]] = None
7650    ) -> bool:
7651        """
7652        Works like `hasCapability`, but also returns `True` if the
7653        player counts as having the specified capability via an equivalence
7654        that's part of the current graph. As with `hasCapability`, the
7655        optional `step` argument is used to specify which step to check,
7656        with the current step being used as the default.
7657
7658        The `location` set can specify where to start looking for
7659        mechanisms; if left unspecified active decisions for that step
7660        will be used.
7661        """
7662        if step is None:
7663            step = -1
7664        if location is None:
7665            location = self.getActiveDecisions(step)
7666        situation = self.getSituation(step)
7667        return base.hasCapabilityOrEquivalent(
7668            capability,
7669            base.RequirementContext(
7670                state=situation.state,
7671                graph=situation.graph,
7672                searchFrom=location
7673            )
7674        )

Works like hasCapability, but also returns True if the player counts as having the specified capability via an equivalence that's part of the current graph. As with hasCapability, the optional step argument is used to specify which step to check, with the current step being used as the default.

The location set can specify where to start looking for mechanisms; if left unspecified active decisions for that step will be used.

def gainCapabilityNow(self, capability: str, inCommon: bool = False) -> None:
7676    def gainCapabilityNow(
7677        self,
7678        capability: base.Capability,
7679        inCommon: bool = False
7680    ) -> None:
7681        """
7682        Modifies the current game state to add the specified `Capability`
7683        to the player's capabilities. No changes are made to the current
7684        graph.
7685
7686        If `inCommon` is set to `True` (default is `False`) then the
7687        capability will be added to the common `FocalContext` and will
7688        therefore persist even when a focal context switch happens.
7689        Normally, it will be added to the currently-active focal
7690        context.
7691        """
7692        state = self.getSituation().state
7693        if inCommon:
7694            context = state['common']
7695        else:
7696            context = state['contexts'][state['activeContext']]
7697        context['capabilities']['capabilities'].add(capability)

Modifies the current game state to add the specified Capability to the player's capabilities. No changes are made to the current graph.

If inCommon is set to True (default is False) then the capability will be added to the common FocalContext and will therefore persist even when a focal context switch happens. Normally, it will be added to the currently-active focal context.

def loseCapabilityNow( self, capability: str, inCommon: Union[bool, Literal['both']] = 'both') -> None:
7699    def loseCapabilityNow(
7700        self,
7701        capability: base.Capability,
7702        inCommon: Union[bool, Literal['both']] = "both"
7703    ) -> None:
7704        """
7705        Modifies the current game state to remove the specified `Capability`
7706        from the player's capabilities. Does nothing if the player
7707        doesn't already have that capability.
7708
7709        By default, this removes the capability from both the common
7710        capabilities set and the active `FocalContext`'s capabilities
7711        set, so that afterwards the player will definitely not have that
7712        capability. However, if you set `inCommon` to either `True` or
7713        `False`, it will remove the capability from just the common
7714        capabilities set (if `True`) or just the active capabilities set
7715        (if `False`). In these cases, removing the capability from just
7716        one capability set will not actually remove it in terms of the
7717        `hasCapability` result if it had been present in the other set.
7718        Set `inCommon` to "both" to use the default behavior explicitly.
7719        """
7720        now = self.getSituation()
7721        if inCommon in ("both", True):
7722            context = now.state['common']
7723            try:
7724                context['capabilities']['capabilities'].remove(capability)
7725            except KeyError:
7726                pass
7727        elif inCommon in ("both", False):
7728            context = now.state['contexts'][now.state['activeContext']]
7729            try:
7730                context['capabilities']['capabilities'].remove(capability)
7731            except KeyError:
7732                pass
7733        else:
7734            raise ValueError(
7735                f"Invalid inCommon value (must be False, True, or"
7736                f" 'both'; got {repr(inCommon)})."
7737            )

Modifies the current game state to remove the specified Capability from the player's capabilities. Does nothing if the player doesn't already have that capability.

By default, this removes the capability from both the common capabilities set and the active FocalContext's capabilities set, so that afterwards the player will definitely not have that capability. However, if you set inCommon to either True or False, it will remove the capability from just the common capabilities set (if True) or just the active capabilities set (if False). In these cases, removing the capability from just one capability set will not actually remove it in terms of the hasCapability result if it had been present in the other set. Set inCommon to "both" to use the default behavior explicitly.

def tokenCountNow(self, tokenType: str) -> Optional[int]:
7739    def tokenCountNow(self, tokenType: base.Token) -> Optional[int]:
7740        """
7741        Returns the number of tokens the player currently has of a given
7742        type. Returns `None` if the player has never acquired or lost
7743        tokens of that type.
7744
7745        This method adds together tokens from the common and active
7746        focal contexts.
7747        """
7748        state = self.getSituation().state
7749        commonContext = state['common']
7750        activeContext = state['contexts'][state['activeContext']]
7751        base = commonContext['capabilities']['tokens'].get(tokenType)
7752        if base is None:
7753            return activeContext['capabilities']['tokens'].get(tokenType)
7754        else:
7755            return base + activeContext['capabilities']['tokens'].get(
7756                tokenType,
7757                0
7758            )

Returns the number of tokens the player currently has of a given type. Returns None if the player has never acquired or lost tokens of that type.

This method adds together tokens from the common and active focal contexts.

def adjustTokensNow(self, tokenType: str, amount: int, inCommon: bool = False) -> None:
7760    def adjustTokensNow(
7761        self,
7762        tokenType: base.Token,
7763        amount: int,
7764        inCommon: bool = False
7765    ) -> None:
7766        """
7767        Modifies the current game state to add the specified number of
7768        `Token`s of the given type to the player's tokens. No changes are
7769        made to the current graph. Reduce the number of tokens by
7770        supplying a negative amount; note that negative token amounts
7771        are possible.
7772
7773        By default, the number of tokens for the current active
7774        `FocalContext` will be adjusted. However, if `inCommon` is set
7775        to `True`, then the number of tokens for the common context will
7776        be adjusted instead.
7777        """
7778        # TODO: Custom token caps!
7779        state = self.getSituation().state
7780        if inCommon:
7781            context = state['common']
7782        else:
7783            context = state['contexts'][state['activeContext']]
7784        tokens = context['capabilities']['tokens']
7785        tokens[tokenType] = tokens.get(tokenType, 0) + amount

Modifies the current game state to add the specified number of Tokens of the given type to the player's tokens. No changes are made to the current graph. Reduce the number of tokens by supplying a negative amount; note that negative token amounts are possible.

By default, the number of tokens for the current active FocalContext will be adjusted. However, if inCommon is set to True, then the number of tokens for the common context will be adjusted instead.

def setTokensNow(self, tokenType: str, amount: int, inCommon: bool = False) -> None:
7787    def setTokensNow(
7788        self,
7789        tokenType: base.Token,
7790        amount: int,
7791        inCommon: bool = False
7792    ) -> None:
7793        """
7794        Modifies the current game state to set number of `Token`s of the
7795        given type to a specific amount, regardless of the old value. No
7796        changes are made to the current graph.
7797
7798        By default this sets the number of tokens for the active
7799        `FocalContext`. But if you set `inCommon` to `True`, it will
7800        set the number of tokens in the common context instead.
7801        """
7802        # TODO: Custom token caps!
7803        state = self.getSituation().state
7804        if inCommon:
7805            context = state['common']
7806        else:
7807            context = state['contexts'][state['activeContext']]
7808        context['capabilities']['tokens'][tokenType] = amount

Modifies the current game state to set number of Tokens of the given type to a specific amount, regardless of the old value. No changes are made to the current graph.

By default this sets the number of tokens for the active FocalContext. But if you set inCommon to True, it will set the number of tokens in the common context instead.

def lookupMechanism( self, mechanism: str, step: Optional[int] = None, where: Union[Tuple[Union[int, exploration.base.DecisionSpecifier, str], Optional[str]], Collection[Union[int, exploration.base.DecisionSpecifier, str]], NoneType] = None) -> int:
7810    def lookupMechanism(
7811        self,
7812        mechanism: base.MechanismName,
7813        step: Optional[int] = None,
7814        where: Union[
7815            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
7816            Collection[base.AnyDecisionSpecifier],
7817            None
7818        ] = None
7819    ) -> base.MechanismID:
7820        """
7821        Looks up a mechanism ID by name, in the graph for the specified
7822        step. The `where` argument specifies where to start looking,
7823        which helps disambiguate. It can be a tuple with a decision
7824        specifier and `None` to start from a single decision, or with a
7825        decision specifier and a transition name to start from either
7826        end of that transition. It can also be `None` to look at global
7827        mechanisms and then all decisions directly, although this
7828        increases the chance of a `MechanismCollisionError`. Finally, it
7829        can be some other non-tuple collection of decision specifiers to
7830        start from that set.
7831
7832        If no step is specified, uses the current step.
7833        """
7834        if step is None:
7835            step = -1
7836        situation = self.getSituation(step)
7837        graph = situation.graph
7838        searchFrom: Collection[base.AnyDecisionSpecifier]
7839        if where is None:
7840            searchFrom = set()
7841        elif isinstance(where, tuple):
7842            if len(where) != 2:
7843                raise ValueError(
7844                    f"Mechanism lookup location was a tuple with an"
7845                    f" invalid length (must be length-2 if it's a"
7846                    f" tuple):\n  {repr(where)}"
7847                )
7848            where = cast(
7849                Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
7850                where
7851            )
7852            if where[1] is None:
7853                searchFrom = {graph.resolveDecision(where[0])}
7854            else:
7855                searchFrom = graph.bothEnds(where[0], where[1])
7856        else:  # must be a collection of specifiers
7857            searchFrom = cast(Collection[base.AnyDecisionSpecifier], where)
7858        return graph.lookupMechanism(searchFrom, mechanism)

Looks up a mechanism ID by name, in the graph for the specified step. The where argument specifies where to start looking, which helps disambiguate. It can be a tuple with a decision specifier and None to start from a single decision, or with a decision specifier and a transition name to start from either end of that transition. It can also be None to look at global mechanisms and then all decisions directly, although this increases the chance of a MechanismCollisionError. Finally, it can be some other non-tuple collection of decision specifiers to start from that set.

If no step is specified, uses the current step.

def mechanismState( self, mechanism: Union[int, str, exploration.base.MechanismSpecifier], where: Optional[Set[int]] = None, step: int = -1) -> Optional[str]:
7860    def mechanismState(
7861        self,
7862        mechanism: base.AnyMechanismSpecifier,
7863        where: Optional[Set[base.DecisionID]] = None,
7864        step: int = -1
7865    ) -> Optional[base.MechanismState]:
7866        """
7867        Returns the current state for the specified mechanism (or the
7868        state at the specified step if a step index is given). `where`
7869        may be provided as a set of decision IDs to indicate where to
7870        search for the named mechanism, or a mechanism ID may be provided
7871        in the first place. Mechanism states are properties of a `State`
7872        but are not associated with focal contexts.
7873        """
7874        situation = self.getSituation(step)
7875        mID = situation.graph.resolveMechanism(mechanism, startFrom=where)
7876        return situation.state['mechanisms'].get(
7877            mID,
7878            base.DEFAULT_MECHANISM_STATE
7879        )

Returns the current state for the specified mechanism (or the state at the specified step if a step index is given). where may be provided as a set of decision IDs to indicate where to search for the named mechanism, or a mechanism ID may be provided in the first place. Mechanism states are properties of a State but are not associated with focal contexts.

def setMechanismStateNow( self, mechanism: Union[int, str, exploration.base.MechanismSpecifier], toState: str, where: Optional[Set[int]] = None) -> None:
7881    def setMechanismStateNow(
7882        self,
7883        mechanism: base.AnyMechanismSpecifier,
7884        toState: base.MechanismState,
7885        where: Optional[Set[base.DecisionID]] = None
7886    ) -> None:
7887        """
7888        Sets the state of the specified mechanism to the specified
7889        state. Mechanisms can only be in one state at once, so this
7890        removes any previous states for that mechanism (note that via
7891        equivalences multiple mechanism states can count as active).
7892
7893        The mechanism can be any kind of mechanism specifier (see
7894        `base.AnyMechanismSpecifier`). If it's not a mechanism ID and
7895        doesn't have its own position information, the 'where' argument
7896        can be used to hint where to search for the mechanism.
7897        """
7898        now = self.getSituation()
7899        mID = now.graph.resolveMechanism(mechanism, startFrom=where)
7900        if mID is None:
7901            raise MissingMechanismError(
7902                f"Couldn't find mechanism for {repr(mechanism)}."
7903            )
7904        now.state['mechanisms'][mID] = toState

Sets the state of the specified mechanism to the specified state. Mechanisms can only be in one state at once, so this removes any previous states for that mechanism (note that via equivalences multiple mechanism states can count as active).

The mechanism can be any kind of mechanism specifier (see base.AnyMechanismSpecifier). If it's not a mechanism ID and doesn't have its own position information, the 'where' argument can be used to hint where to search for the mechanism.

def skillLevel(self, skill: str, step: Optional[int] = None) -> Optional[int]:
7906    def skillLevel(
7907        self,
7908        skill: base.Skill,
7909        step: Optional[int] = None
7910    ) -> Optional[base.Level]:
7911        """
7912        Returns the skill level the player had in a given skill at a
7913        given step, or for the current step if no step is specified.
7914        Returns `None` if the player had never acquired or lost levels
7915        in that skill before the specified step (skill level would count
7916        as 0 in that case).
7917
7918        This method adds together levels from the common and active
7919        focal contexts.
7920        """
7921        if step is None:
7922            step = -1
7923        state = self.getSituation(step).state
7924        commonContext = state['common']
7925        activeContext = state['contexts'][state['activeContext']]
7926        base = commonContext['capabilities']['skills'].get(skill)
7927        if base is None:
7928            return activeContext['capabilities']['skills'].get(skill)
7929        else:
7930            return base + activeContext['capabilities']['skills'].get(
7931                skill,
7932                0
7933            )

Returns the skill level the player had in a given skill at a given step, or for the current step if no step is specified. Returns None if the player had never acquired or lost levels in that skill before the specified step (skill level would count as 0 in that case).

This method adds together levels from the common and active focal contexts.

def adjustSkillLevelNow(self, skill: str, levels: int, inCommon: bool = False) -> None:
7935    def adjustSkillLevelNow(
7936        self,
7937        skill: base.Skill,
7938        levels: base.Level,
7939        inCommon: bool = False
7940    ) -> None:
7941        """
7942        Modifies the current game state to add the specified number of
7943        `Level`s of the given skill. No changes are made to the current
7944        graph. Reduce the skill level by supplying negative levels; note
7945        that negative skill levels are possible.
7946
7947        By default, the skill level for the current active
7948        `FocalContext` will be adjusted. However, if `inCommon` is set
7949        to `True`, then the skill level for the common context will be
7950        adjusted instead.
7951        """
7952        # TODO: Custom level caps?
7953        state = self.getSituation().state
7954        if inCommon:
7955            context = state['common']
7956        else:
7957            context = state['contexts'][state['activeContext']]
7958        skills = context['capabilities']['skills']
7959        skills[skill] = skills.get(skill, 0) + levels

Modifies the current game state to add the specified number of Levels of the given skill. No changes are made to the current graph. Reduce the skill level by supplying negative levels; note that negative skill levels are possible.

By default, the skill level for the current active FocalContext will be adjusted. However, if inCommon is set to True, then the skill level for the common context will be adjusted instead.

def setSkillLevelNow(self, skill: str, level: int, inCommon: bool = False) -> None:
7961    def setSkillLevelNow(
7962        self,
7963        skill: base.Skill,
7964        level: base.Level,
7965        inCommon: bool = False
7966    ) -> None:
7967        """
7968        Modifies the current game state to set `Skill` `Level` for the
7969        given skill, regardless of the old value. No changes are made to
7970        the current graph.
7971
7972        By default this sets the skill level for the active
7973        `FocalContext`. But if you set `inCommon` to `True`, it will set
7974        the skill level in the common context instead.
7975        """
7976        # TODO: Custom level caps?
7977        state = self.getSituation().state
7978        if inCommon:
7979            context = state['common']
7980        else:
7981            context = state['contexts'][state['activeContext']]
7982        skills = context['capabilities']['skills']
7983        skills[skill] = level

Modifies the current game state to set Skill Level for the given skill, regardless of the old value. No changes are made to the current graph.

By default this sets the skill level for the active FocalContext. But if you set inCommon to True, it will set the skill level in the common context instead.

def updateRequirementNow( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, requirement: Optional[exploration.base.Requirement]) -> None:
7985    def updateRequirementNow(
7986        self,
7987        decision: base.AnyDecisionSpecifier,
7988        transition: base.Transition,
7989        requirement: Optional[base.Requirement]
7990    ) -> None:
7991        """
7992        Updates the requirement for a specific transition in a specific
7993        decision. Use `None` to remove the requirement for that edge.
7994        """
7995        if requirement is None:
7996            requirement = base.ReqNothing()
7997        self.getSituation().graph.setTransitionRequirement(
7998            decision,
7999            transition,
8000            requirement
8001        )

Updates the requirement for a specific transition in a specific decision. Use None to remove the requirement for that edge.

def isTraversable( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: str, step: int = -1) -> bool:
8003    def isTraversable(
8004        self,
8005        decision: base.AnyDecisionSpecifier,
8006        transition: base.Transition,
8007        step: int = -1
8008    ) -> bool:
8009        """
8010        Returns True if the specified transition from the specified
8011        decision had its requirement satisfied by the game state at the
8012        specified step (or at the current step if no step is specified).
8013        Raises an `IndexError` if the specified step doesn't exist, and
8014        a `KeyError` if the decision or transition specified does not
8015        exist in the `DecisionGraph` at that step.
8016        """
8017        situation = self.getSituation(step)
8018        req = situation.graph.getTransitionRequirement(decision, transition)
8019        ctx = base.contextForTransition(situation, decision, transition)
8020        fromID = situation.graph.resolveDecision(decision)
8021        return (
8022            req.satisfied(ctx)
8023        and (fromID, transition) not in situation.state['deactivated']
8024        )

Returns True if the specified transition from the specified decision had its requirement satisfied by the game state at the specified step (or at the current step if no step is specified). Raises an IndexError if the specified step doesn't exist, and a KeyError if the decision or transition specified does not exist in the DecisionGraph at that step.

def applyTransitionEffect( self, whichEffect: Tuple[int, str, int], moveWhich: Optional[str] = None) -> Optional[int]:
8026    def applyTransitionEffect(
8027        self,
8028        whichEffect: base.EffectSpecifier,
8029        moveWhich: Optional[base.FocalPointName] = None
8030    ) -> Optional[base.DecisionID]:
8031        """
8032        Applies an effect attached to a transition, taking charges and
8033        delay into account based on the current `Situation`.
8034        Modifies the effect's trigger count (but may not actually
8035        trigger the effect if the charges and/or delay values indicate
8036        not to; see `base.doTriggerEffect`).
8037
8038        If a specific focal point in a plural-focalized domain is
8039        triggering the effect, the focal point name should be specified
8040        via `moveWhich` so that goto `Effect`s can know which focal
8041        point to move when it's not explicitly specified in the effect.
8042        TODO: Test this!
8043
8044        Returns None most of the time, but if a 'goto', 'bounce', or
8045        'follow' effect was applied, it returns the decision ID for that
8046        effect's destination, which would override a transition's normal
8047        destination. If it returns a destination ID, then the exploration
8048        state will already have been updated to set the position there,
8049        and further position updates are not needed.
8050
8051        Note that transition effects which update active decisions will
8052        also update the exploration status of those decisions to
8053        'exploring' if they had been in an unvisited status (see
8054        `updatePosition` and `hasBeenVisited`).
8055
8056        Note: callers should immediately update situation-based variables
8057        that might have been changes by a 'revert' effect.
8058        """
8059        now = self.getSituation()
8060        effect, triggerCount = base.doTriggerEffect(now, whichEffect)
8061        if triggerCount is not None:
8062            return self.applyExtraneousEffect(
8063                effect,
8064                where=whichEffect[:2],
8065                moveWhich=moveWhich
8066            )
8067        else:
8068            return None

Applies an effect attached to a transition, taking charges and delay into account based on the current Situation. Modifies the effect's trigger count (but may not actually trigger the effect if the charges and/or delay values indicate not to; see base.doTriggerEffect).

If a specific focal point in a plural-focalized domain is triggering the effect, the focal point name should be specified via moveWhich so that goto Effects can know which focal point to move when it's not explicitly specified in the effect. TODO: Test this!

Returns None most of the time, but if a 'goto', 'bounce', or 'follow' effect was applied, it returns the decision ID for that effect's destination, which would override a transition's normal destination. If it returns a destination ID, then the exploration state will already have been updated to set the position there, and further position updates are not needed.

Note that transition effects which update active decisions will also update the exploration status of those decisions to 'exploring' if they had been in an unvisited status (see updatePosition and hasBeenVisited).

Note: callers should immediately update situation-based variables that might have been changes by a 'revert' effect.

def applyExtraneousEffect( self, effect: exploration.base.Effect, where: Optional[Tuple[Union[int, exploration.base.DecisionSpecifier, str], Optional[str]]] = None, moveWhich: Optional[str] = None, challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> Optional[int]:
8070    def applyExtraneousEffect(
8071        self,
8072        effect: base.Effect,
8073        where: Optional[
8074            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
8075        ] = None,
8076        moveWhich: Optional[base.FocalPointName] = None,
8077        challengePolicy: base.ChallengePolicy = "specified"
8078    ) -> Optional[base.DecisionID]:
8079        """
8080        Applies a single extraneous effect to the state & graph,
8081        *without* accounting for charges or delay values, since the
8082        effect is not part of the graph (use `applyTransitionEffect` to
8083        apply effects that are attached to transitions, which is almost
8084        always the function you should be using). An associated
8085        transition for the extraneous effect can be supplied using the
8086        `where` argument, and effects like 'deactivate' and 'edit' will
8087        affect it (but the effect's charges and delay values will still
8088        be ignored).
8089
8090        If the effect would change the destination of a transition, the
8091        altered destination ID is returned: 'bounce' effects return the
8092        provided decision part of `where`, 'goto' effects return their
8093        target, and 'follow' effects return the destination followed to
8094        (possibly via chained follows in the extreme case). In all other
8095        cases, `None` is returned indicating no change to a normal
8096        destination.
8097
8098        If a specific focal point in a plural-focalized domain is
8099        triggering the effect, the focal point name should be specified
8100        via `moveWhich` so that goto `Effect`s can know which focal
8101        point to move when it's not explicitly specified in the effect.
8102        TODO: Test this!
8103
8104        Note that transition effects which update active decisions will
8105        also update the exploration status of those decisions to
8106        'exploring' if they had been in an unvisited status and will
8107        remove any 'unconfirmed' tag they might still have (see
8108        `updatePosition` and `hasBeenVisited`).
8109
8110        The given `challengePolicy` is applied when traversing further
8111        transitions due to 'follow' effects.
8112
8113        Note: Anyone calling `applyExtraneousEffect` should update any
8114        situation-based variables immediately after the call, as a
8115        'revert' effect may have changed the current graph and/or state.
8116        """
8117        typ = effect['type']
8118        value = effect['value']
8119        applyTo = effect['applyTo']
8120        inCommon = applyTo == 'common'
8121
8122        now = self.getSituation()
8123
8124        if where is not None:
8125            if where[1] is not None:
8126                searchFrom = now.graph.bothEnds(where[0], where[1])
8127            else:
8128                searchFrom = {now.graph.resolveDecision(where[0])}
8129        else:
8130            searchFrom = None
8131
8132        # Note: Delay and charges are ignored!
8133
8134        if typ in ("gain", "lose"):
8135            value = cast(
8136                Union[
8137                    base.Capability,
8138                    Tuple[base.Token, base.TokenCount],
8139                    Tuple[Literal['skill'], base.Skill, base.Level],
8140                ],
8141                value
8142            )
8143            if isinstance(value, base.Capability):
8144                if typ == "gain":
8145                    self.gainCapabilityNow(value, inCommon)
8146                else:
8147                    self.loseCapabilityNow(value, inCommon)
8148            elif len(value) == 2:  # must be a token, amount pair
8149                token, amount = cast(
8150                    Tuple[base.Token, base.TokenCount],
8151                    value
8152                )
8153                if typ == "lose":
8154                    amount *= -1
8155                self.adjustTokensNow(token, amount, inCommon)
8156            else:  # must be a 'skill', skill, level triple
8157                _, skill, levels = cast(
8158                    Tuple[Literal['skill'], base.Skill, base.Level],
8159                    value
8160                )
8161                if typ == "lose":
8162                    levels *= -1
8163                self.adjustSkillLevelNow(skill, levels, inCommon)
8164
8165        elif typ == "set":
8166            value = cast(
8167                Union[
8168                    Tuple[base.Token, base.TokenCount],
8169                    Tuple[base.AnyMechanismSpecifier, base.MechanismState],
8170                    Tuple[Literal['skill'], base.Skill, base.Level],
8171                ],
8172                value
8173            )
8174            if len(value) == 2:  # must be a token or mechanism pair
8175                if isinstance(value[1], base.TokenCount):  # token
8176                    token, amount = cast(
8177                        Tuple[base.Token, base.TokenCount],
8178                        value
8179                    )
8180                    self.setTokensNow(token, amount, inCommon)
8181                else: # mechanism
8182                    mechanism, state = cast(
8183                        Tuple[
8184                            base.AnyMechanismSpecifier,
8185                            base.MechanismState
8186                        ],
8187                        value
8188                    )
8189                    self.setMechanismStateNow(mechanism, state, searchFrom)
8190            else:  # must be a 'skill', skill, level triple
8191                _, skill, level = cast(
8192                    Tuple[Literal['skill'], base.Skill, base.Level],
8193                    value
8194                )
8195                self.setSkillLevelNow(skill, level, inCommon)
8196
8197        elif typ == "toggle":
8198            # Length-1 list just toggles a capability on/off based on current
8199            # state (not attending to equivalents):
8200            if isinstance(value, List):  # capabilities list
8201                value = cast(List[base.Capability], value)
8202                if len(value) == 0:
8203                    raise ValueError(
8204                        "Toggle effect has empty capabilities list."
8205                    )
8206                if len(value) == 1:
8207                    capability = value[0]
8208                    if self.hasCapability(capability, inCommon=False):
8209                        self.loseCapabilityNow(capability, inCommon=False)
8210                    else:
8211                        self.gainCapabilityNow(capability)
8212                else:
8213                    # Otherwise toggle all powers off, then one on,
8214                    # based on the first capability that's currently on.
8215                    # Note we do NOT count equivalences.
8216
8217                    # Find first capability that's on:
8218                    firstIndex: Optional[int] = None
8219                    for i, capability in enumerate(value):
8220                        if self.hasCapability(capability):
8221                            firstIndex = i
8222                            break
8223
8224                    # Turn them all off:
8225                    for capability in value:
8226                        self.loseCapabilityNow(capability, inCommon=False)
8227                        # TODO: inCommon for the check?
8228
8229                    if firstIndex is None:
8230                        self.gainCapabilityNow(value[0])
8231                    else:
8232                        self.gainCapabilityNow(
8233                            value[(firstIndex + 1) % len(value)]
8234                        )
8235            else:  # must be a mechanism w/ states list
8236                mechanism, states = cast(
8237                    Tuple[
8238                        base.AnyMechanismSpecifier,
8239                        List[base.MechanismState]
8240                    ],
8241                    value
8242                )
8243                currentState = self.mechanismState(mechanism, where=searchFrom)
8244                if len(states) == 1:
8245                    if currentState == states[0]:
8246                        # default alternate state
8247                        self.setMechanismStateNow(
8248                            mechanism,
8249                            base.DEFAULT_MECHANISM_STATE,
8250                            searchFrom
8251                        )
8252                    else:
8253                        self.setMechanismStateNow(
8254                            mechanism,
8255                            states[0],
8256                            searchFrom
8257                        )
8258                else:
8259                    # Find our position in the list, if any
8260                    try:
8261                        currentIndex = states.index(cast(str, currentState))
8262                        # Cast here just because we know that None will
8263                        # raise a ValueError but we'll catch it, and we
8264                        # want to suppress the mypy warning about the
8265                        # option
8266                    except ValueError:
8267                        currentIndex = len(states) - 1
8268                    # Set next state in list as current state
8269                    nextIndex = (currentIndex + 1) % len(states)
8270                    self.setMechanismStateNow(
8271                        mechanism,
8272                        states[nextIndex],
8273                        searchFrom
8274                    )
8275
8276        elif typ == "deactivate":
8277            if where is None or where[1] is None:
8278                raise ValueError(
8279                    "Can't apply a deactivate effect without specifying"
8280                    " which transition it applies to."
8281                )
8282
8283            decision, transition = cast(
8284                Tuple[base.AnyDecisionSpecifier, base.Transition],
8285                where
8286            )
8287
8288            dID = now.graph.resolveDecision(decision)
8289            now.state['deactivated'].add((dID, transition))
8290
8291        elif typ == "edit":
8292            value = cast(List[List[commands.Command]], value)
8293            # If there are no blocks, do nothing
8294            if len(value) > 0:
8295                # Apply the first block of commands and then rotate the list
8296                scope: commands.Scope = {}
8297                if where is not None:
8298                    here: base.DecisionID = now.graph.resolveDecision(
8299                        where[0]
8300                    )
8301                    outwards: Optional[base.Transition] = where[1]
8302                    scope['@'] = here
8303                    scope['@t'] = outwards
8304                    if outwards is not None:
8305                        reciprocal = now.graph.getReciprocal(here, outwards)
8306                        destination = now.graph.getDestination(here, outwards)
8307                    else:
8308                        reciprocal = None
8309                        destination = None
8310                    scope['@r'] = reciprocal
8311                    scope['@d'] = destination
8312                self.runCommandBlock(value[0], scope)
8313                value.append(value.pop(0))
8314
8315        elif typ == "goto":
8316            if isinstance(value, base.DecisionSpecifier):
8317                target: base.AnyDecisionSpecifier = value
8318                # use moveWhich provided as argument
8319            elif isinstance(value, tuple):
8320                target, moveWhich = cast(
8321                    Tuple[base.AnyDecisionSpecifier, base.FocalPointName],
8322                    value
8323                )
8324            else:
8325                target = cast(base.AnyDecisionSpecifier, value)
8326                # use moveWhich provided as argument
8327
8328            destID = now.graph.resolveDecision(target)
8329            base.updatePosition(now, destID, applyTo, moveWhich)
8330            return destID
8331
8332        elif typ == "bounce":
8333            # Just need to let the caller know they should cancel
8334            if where is None:
8335                raise ValueError(
8336                    "Can't apply a 'bounce' effect without a position"
8337                    " to apply it from."
8338                )
8339            return now.graph.resolveDecision(where[0])
8340
8341        elif typ == "follow":
8342            if where is None:
8343                raise ValueError(
8344                    f"Can't follow transition {value!r} because there"
8345                    f" is no position information when applying the"
8346                    f" effect."
8347                )
8348            if where[1] is not None:
8349                followFrom = now.graph.getDestination(where[0], where[1])
8350                if followFrom is None:
8351                    raise ValueError(
8352                        f"Can't follow transition {value!r} because the"
8353                        f" position information specifies transition"
8354                        f" {where[1]!r} from decision"
8355                        f" {now.graph.identityOf(where[0])} but that"
8356                        f" transition does not exist."
8357                    )
8358            else:
8359                followFrom = now.graph.resolveDecision(where[0])
8360
8361            following = cast(base.Transition, value)
8362
8363            followTo = now.graph.getDestination(followFrom, following)
8364
8365            if followTo is None:
8366                raise ValueError(
8367                    f"Can't follow transition {following!r} because"
8368                    f" that transition doesn't exist at the specified"
8369                    f" destination {now.graph.identityOf(followFrom)}."
8370                )
8371
8372            if self.isTraversable(followFrom, following):  # skip if not
8373                # Perform initial position update before following new
8374                # transition:
8375                base.updatePosition(
8376                    now,
8377                    followFrom,
8378                    applyTo,
8379                    moveWhich
8380                )
8381
8382                # Apply consequences of followed transition
8383                fullFollowTo = self.applyTransitionConsequence(
8384                    followFrom,
8385                    following,
8386                    moveWhich,
8387                    challengePolicy
8388                )
8389
8390                # Now update to end of followed transition
8391                if fullFollowTo is None:
8392                    base.updatePosition(
8393                        now,
8394                        followTo,
8395                        applyTo,
8396                        moveWhich
8397                    )
8398                    fullFollowTo = followTo
8399
8400                # Skip the normal update: we've taken care of that plus more
8401                return fullFollowTo
8402            else:
8403                # Normal position updates still applies since follow
8404                # transition wasn't possible
8405                return None
8406
8407        elif typ == "save":
8408            assert isinstance(value, base.SaveSlot)
8409            now.saves[value] = copy.deepcopy((now.graph, now.state))
8410
8411        else:
8412            raise ValueError(f"Invalid effect type {typ!r}.")
8413
8414        return None  # default return value if we didn't return above

Applies a single extraneous effect to the state & graph, without accounting for charges or delay values, since the effect is not part of the graph (use applyTransitionEffect to apply effects that are attached to transitions, which is almost always the function you should be using). An associated transition for the extraneous effect can be supplied using the where argument, and effects like 'deactivate' and 'edit' will affect it (but the effect's charges and delay values will still be ignored).

If the effect would change the destination of a transition, the altered destination ID is returned: 'bounce' effects return the provided decision part of where, 'goto' effects return their target, and 'follow' effects return the destination followed to (possibly via chained follows in the extreme case). In all other cases, None is returned indicating no change to a normal destination.

If a specific focal point in a plural-focalized domain is triggering the effect, the focal point name should be specified via moveWhich so that goto Effects can know which focal point to move when it's not explicitly specified in the effect. TODO: Test this!

Note that transition effects which update active decisions will also update the exploration status of those decisions to 'exploring' if they had been in an unvisited status and will remove any 'unconfirmed' tag they might still have (see updatePosition and hasBeenVisited).

The given challengePolicy is applied when traversing further transitions due to 'follow' effects.

Note: Anyone calling applyExtraneousEffect should update any situation-based variables immediately after the call, as a 'revert' effect may have changed the current graph and/or state.

def applyExtraneousConsequence( self, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], where: Optional[Tuple[Union[int, exploration.base.DecisionSpecifier, str], Optional[str]]] = None, moveWhich: Optional[str] = None) -> Optional[int]:
8416    def applyExtraneousConsequence(
8417        self,
8418        consequence: base.Consequence,
8419        where: Optional[
8420            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
8421        ] = None,
8422        moveWhich: Optional[base.FocalPointName] = None
8423    ) -> Optional[base.DecisionID]:
8424        """
8425        Applies an extraneous consequence not associated with a
8426        transition. Unlike `applyTransitionConsequence`, the provided
8427        `base.Consequence` must already have observed outcomes (see
8428        `base.observeChallengeOutcomes`). Returns the decision ID for a
8429        decision implied by a goto, follow, or bounce effect, or `None`
8430        if no effect implies a destination.
8431
8432        The `where` and `moveWhich` optional arguments specify which
8433        decision and/or transition to use as the application position,
8434        and/or which focal point to move. This affects mechanism lookup
8435        as well as the end position when 'follow' effects are used.
8436        Specifically:
8437
8438        - A 'follow' trigger will search for transitions to follow from
8439            the destination of the specified transition, or if only a
8440            decision was supplied, from that decision.
8441        - Mechanism lookups will start with both ends of the specified
8442            transition as their search field (or with just the specified
8443            decision if no transition is included).
8444
8445        'bounce' effects will cause an error unless position information
8446        is provided, and will set the position to the base decision
8447        provided in `where`.
8448
8449        Note: callers should update any situation-based variables
8450        immediately after calling this as a 'revert' effect could change
8451        the current graph and/or state and other changes could get lost
8452        if they get applied to a stale graph/state.
8453
8454        # TODO: Examples for goto and follow effects.
8455        """
8456        now = self.getSituation()
8457        searchFrom = set()
8458        if where is not None:
8459            if where[1] is not None:
8460                searchFrom = now.graph.bothEnds(where[0], where[1])
8461            else:
8462                searchFrom = {now.graph.resolveDecision(where[0])}
8463
8464        context = base.RequirementContext(
8465            state=now.state,
8466            graph=now.graph,
8467            searchFrom=searchFrom
8468        )
8469
8470        effectIndices = base.observedEffects(context, consequence)
8471        destID = None
8472        for index in effectIndices:
8473            effect = base.consequencePart(consequence, index)
8474            if not isinstance(effect, dict) or 'value' not in effect:
8475                raise RuntimeError(
8476                    f"Invalid effect index {index}: Consequence part at"
8477                    f" that index is not an Effect. Got:\n{effect}"
8478                )
8479            effect = cast(base.Effect, effect)
8480            destID = self.applyExtraneousEffect(
8481                effect,
8482                where,
8483                moveWhich
8484            )
8485            # technically this variable is not used later in this
8486            # function, but the `applyExtraneousEffect` call means it
8487            # needs an update, so we're doing that in case someone later
8488            # adds code to this function that uses 'now' after this
8489            # point.
8490            now = self.getSituation()
8491
8492        return destID

Applies an extraneous consequence not associated with a transition. Unlike applyTransitionConsequence, the provided base.Consequence must already have observed outcomes (see base.observeChallengeOutcomes). Returns the decision ID for a decision implied by a goto, follow, or bounce effect, or None if no effect implies a destination.

The where and moveWhich optional arguments specify which decision and/or transition to use as the application position, and/or which focal point to move. This affects mechanism lookup as well as the end position when 'follow' effects are used. Specifically:

  • A 'follow' trigger will search for transitions to follow from the destination of the specified transition, or if only a decision was supplied, from that decision.
  • Mechanism lookups will start with both ends of the specified transition as their search field (or with just the specified decision if no transition is included).

'bounce' effects will cause an error unless position information is provided, and will set the position to the base decision provided in where.

Note: callers should update any situation-based variables immediately after calling this as a 'revert' effect could change the current graph and/or state and other changes could get lost if they get applied to a stale graph/state.

TODO: Examples for goto and follow effects.

def applyTransitionConsequence( self, decision: Union[int, exploration.base.DecisionSpecifier, str], transition: Union[str, Tuple[str, List[bool]]], moveWhich: Optional[str] = None, policy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified', fromIndex: Optional[int] = None, toIndex: Optional[int] = None) -> Optional[int]:
8494    def applyTransitionConsequence(
8495        self,
8496        decision: base.AnyDecisionSpecifier,
8497        transition: base.AnyTransition,
8498        moveWhich: Optional[base.FocalPointName] = None,
8499        policy: base.ChallengePolicy = "specified",
8500        fromIndex: Optional[int] = None,
8501        toIndex: Optional[int] = None
8502    ) -> Optional[base.DecisionID]:
8503        """
8504        Applies the effects of the specified transition to the current
8505        graph and state, possibly overriding observed outcomes using
8506        outcomes specified as part of a `base.TransitionWithOutcomes`.
8507
8508        The `where` and `moveWhich` function serve the same purpose as
8509        for `applyExtraneousEffect`. If `where` is `None`, then the
8510        effects will be applied as extraneous effects, meaning that
8511        their delay and charges values will be ignored and their trigger
8512        count will not be tracked. If `where` is supplied
8513
8514        Returns either None to indicate that the position update for the
8515        transition should apply as usual, or a decision ID indicating
8516        another destination which has already been applied by a
8517        transition effect.
8518
8519        If `fromIndex` and/or `toIndex` are specified, then only effects
8520        which have indices between those two (inclusive) will be
8521        applied, and other effects will neither apply nor be updated in
8522        any way. Note that `onlyPart` does not override the challenge
8523        policy: if the effects in the specified part are not applied due
8524        to a challenge outcome, they still won't happen, including
8525        challenge outcomes outside of that part. Also, outcomes for
8526        challenges of the entire consequence are re-observed if the
8527        challenge policy implies it.
8528
8529        Note: Anyone calling this should update any situation-based
8530        variables immediately after the call, as a 'revert' effect may
8531        have changed the current graph and/or state.
8532        """
8533        now = self.getSituation()
8534        dID = now.graph.resolveDecision(decision)
8535
8536        transitionName, outcomes = base.nameAndOutcomes(transition)
8537
8538        searchFrom = set()
8539        searchFrom = now.graph.bothEnds(dID, transitionName)
8540
8541        context = base.RequirementContext(
8542            state=now.state,
8543            graph=now.graph,
8544            searchFrom=searchFrom
8545        )
8546
8547        consequence = now.graph.getConsequence(dID, transitionName)
8548
8549        # Make sure that challenge outcomes are known
8550        if policy != "specified":
8551            base.resetChallengeOutcomes(consequence)
8552        useUp = outcomes[:]
8553        base.observeChallengeOutcomes(
8554            context,
8555            consequence,
8556            location=searchFrom,
8557            policy=policy,
8558            knownOutcomes=useUp
8559        )
8560        if len(useUp) > 0:
8561            raise ValueError(
8562                f"More outcomes specified than challenges observed in"
8563                f" consequence:\n{consequence}"
8564                f"\nRemaining outcomes:\n{useUp}"
8565            )
8566
8567        # Figure out which effects apply, and apply each of them
8568        effectIndices = base.observedEffects(context, consequence)
8569        if fromIndex is None:
8570            fromIndex = 0
8571
8572        altDest = None
8573        for index in effectIndices:
8574            if (
8575                index >= fromIndex
8576            and (toIndex is None or index <= toIndex)
8577            ):
8578                thisDest = self.applyTransitionEffect(
8579                    (dID, transitionName, index),
8580                    moveWhich
8581                )
8582                if thisDest is not None:
8583                    altDest = thisDest
8584                # TODO: What if this updates state with 'revert' to a
8585                # graph that doesn't contain the same effects?
8586                # TODO: Update 'now' and 'context'?!
8587        return altDest

Applies the effects of the specified transition to the current graph and state, possibly overriding observed outcomes using outcomes specified as part of a base.TransitionWithOutcomes.

The where and moveWhich function serve the same purpose as for applyExtraneousEffect. If where is None, then the effects will be applied as extraneous effects, meaning that their delay and charges values will be ignored and their trigger count will not be tracked. If where is supplied

Returns either None to indicate that the position update for the transition should apply as usual, or a decision ID indicating another destination which has already been applied by a transition effect.

If fromIndex and/or toIndex are specified, then only effects which have indices between those two (inclusive) will be applied, and other effects will neither apply nor be updated in any way. Note that onlyPart does not override the challenge policy: if the effects in the specified part are not applied due to a challenge outcome, they still won't happen, including challenge outcomes outside of that part. Also, outcomes for challenges of the entire consequence are re-observed if the challenge policy implies it.

Note: Anyone calling this should update any situation-based variables immediately after the call, as a 'revert' effect may have changed the current graph and/or state.

def allDecisions(self) -> List[int]:
8589    def allDecisions(self) -> List[base.DecisionID]:
8590        """
8591        Returns the list of all decisions which existed at any point
8592        within the exploration. Example:
8593
8594        >>> ex = DiscreteExploration()
8595        >>> ex.start('A')
8596        0
8597        >>> ex.observe('A', 'right')
8598        1
8599        >>> ex.explore('right', 'B', 'left')
8600        1
8601        >>> ex.observe('B', 'right')
8602        2
8603        >>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
8604        [0, 1, 2]
8605        """
8606        seen = set()
8607        result = []
8608        for situation in self:
8609            for decision in situation.graph:
8610                if decision not in seen:
8611                    result.append(decision)
8612                    seen.add(decision)
8613
8614        return result

Returns the list of all decisions which existed at any point within the exploration. Example:

>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
[0, 1, 2]
def allExploredDecisions(self) -> List[int]:
8616    def allExploredDecisions(self) -> List[base.DecisionID]:
8617        """
8618        Returns the list of all decisions which existed at any point
8619        within the exploration, excluding decisions whose highest
8620        exploration status was `noticed` or lower. May still include
8621        decisions which don't exist in the final situation's graph due to
8622        things like decision merging. Example:
8623
8624        >>> ex = DiscreteExploration()
8625        >>> ex.start('A')
8626        0
8627        >>> ex.observe('A', 'right')
8628        1
8629        >>> ex.explore('right', 'B', 'left')
8630        1
8631        >>> ex.observe('B', 'right')
8632        2
8633        >>> graph = ex.getSituation().graph
8634        >>> graph.addDecision('C')  # add isolated decision; doesn't set status
8635        3
8636        >>> ex.hasBeenVisited('C')
8637        False
8638        >>> ex.allExploredDecisions()
8639        [0, 1]
8640        >>> ex.setExplorationStatus('C', 'exploring')
8641        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
8642        [0, 1, 3]
8643        >>> ex.setExplorationStatus('A', 'explored')
8644        >>> ex.allExploredDecisions()
8645        [0, 1, 3]
8646        >>> ex.setExplorationStatus('A', 'unknown')
8647        >>> # remains visisted in an earlier step
8648        >>> ex.allExploredDecisions()
8649        [0, 1, 3]
8650        >>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
8651        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
8652        [0, 1]
8653        """
8654        seen = set()
8655        result = []
8656        for situation in self:
8657            graph = situation.graph
8658            for decision in graph:
8659                if (
8660                    decision not in seen
8661                and base.hasBeenVisited(situation, decision)
8662                ):
8663                    result.append(decision)
8664                    seen.add(decision)
8665
8666        return result

Returns the list of all decisions which existed at any point within the exploration, excluding decisions whose highest exploration status was noticed or lower. May still include decisions which don't exist in the final situation's graph due to things like decision merging. Example:

>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> graph = ex.getSituation().graph
>>> graph.addDecision('C')  # add isolated decision; doesn't set status
3
>>> ex.hasBeenVisited('C')
False
>>> ex.allExploredDecisions()
[0, 1]
>>> ex.setExplorationStatus('C', 'exploring')
>>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
[0, 1, 3]
>>> ex.setExplorationStatus('A', 'explored')
>>> ex.allExploredDecisions()
[0, 1, 3]
>>> ex.setExplorationStatus('A', 'unknown')
>>> # remains visisted in an earlier step
>>> ex.allExploredDecisions()
[0, 1, 3]
>>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
>>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
[0, 1]
def allVisitedDecisions(self) -> List[int]:
8668    def allVisitedDecisions(self) -> List[base.DecisionID]:
8669        """
8670        Returns the list of all decisions which existed at any point
8671        within the exploration and which were visited at least once.
8672        Orders them in the same order they were visited in.
8673
8674        Usually all of these decisions will be present in the final
8675        situation's graph, but sometimes merging or other factors means
8676        there might be some that won't be. Being present on the game
8677        state's 'active' list in a step for its domain is what counts as
8678        "being visited," which means that nodes which were passed through
8679        directly via a 'follow' effect won't be counted, for example.
8680
8681        This should usually correspond with the absence of the
8682        'unconfirmed' tag.
8683
8684        Example:
8685
8686        >>> ex = DiscreteExploration()
8687        >>> ex.start('A')
8688        0
8689        >>> ex.observe('A', 'right')
8690        1
8691        >>> ex.explore('right', 'B', 'left')
8692        1
8693        >>> ex.observe('B', 'right')
8694        2
8695        >>> ex.getSituation().graph.addDecision('C')  # add isolated decision
8696        3
8697        >>> av = ex.allVisitedDecisions()
8698        >>> av
8699        [0, 1]
8700        >>> all(  # no decisions in the 'visited' list are tagged
8701        ...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
8702        ...     for d in av
8703        ... )
8704        True
8705        >>> graph = ex.getSituation().graph
8706        >>> 'unconfirmed' in graph.decisionTags(0)
8707        False
8708        >>> 'unconfirmed' in graph.decisionTags(1)
8709        False
8710        >>> 'unconfirmed' in graph.decisionTags(2)
8711        True
8712        >>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
8713        False
8714        """
8715        seen = set()
8716        result = []
8717        for step in range(len(self)):
8718            active = self.getActiveDecisions(step)
8719            for dID in active:
8720                if dID not in seen:
8721                    result.append(dID)
8722                    seen.add(dID)
8723
8724        return result

Returns the list of all decisions which existed at any point within the exploration and which were visited at least once. Orders them in the same order they were visited in.

Usually all of these decisions will be present in the final situation's graph, but sometimes merging or other factors means there might be some that won't be. Being present on the game state's 'active' list in a step for its domain is what counts as "being visited," which means that nodes which were passed through directly via a 'follow' effect won't be counted, for example.

This should usually correspond with the absence of the 'unconfirmed' tag.

Example:

>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.getSituation().graph.addDecision('C')  # add isolated decision
3
>>> av = ex.allVisitedDecisions()
>>> av
[0, 1]
>>> all(  # no decisions in the 'visited' list are tagged
...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
...     for d in av
... )
True
>>> graph = ex.getSituation().graph
>>> 'unconfirmed' in graph.decisionTags(0)
False
>>> 'unconfirmed' in graph.decisionTags(1)
False
>>> 'unconfirmed' in graph.decisionTags(2)
True
>>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
False
def allTransitions(self) -> List[Tuple[int, str, int]]:
8726    def allTransitions(self) -> List[
8727        Tuple[base.DecisionID, base.Transition, base.DecisionID]
8728    ]:
8729        """
8730        Returns the list of all transitions which existed at any point
8731        within the exploration, as 3-tuples with source decision ID,
8732        transition name, and destination decision ID. Note that since
8733        transitions can be deleted or re-targeted, and a transition name
8734        can be re-used after being deleted, things can get messy in the
8735        edges cases. When the same transition name is used in different
8736        steps with different decision targets, we end up including each
8737        possible source-transition-destination triple. Example:
8738
8739        >>> ex = DiscreteExploration()
8740        >>> ex.start('A')
8741        0
8742        >>> ex.observe('A', 'right')
8743        1
8744        >>> ex.explore('right', 'B', 'left')
8745        1
8746        >>> ex.observe('B', 'right')
8747        2
8748        >>> ex.wait()  # leave behind a step where 'B' has a 'right'
8749        >>> ex.primaryDecision(0)
8750        >>> ex.primaryDecision(1)
8751        0
8752        >>> ex.primaryDecision(2)
8753        1
8754        >>> ex.primaryDecision(3)
8755        1
8756        >>> len(ex)
8757        4
8758        >>> ex[3].graph.removeDecision(2)  # delete 'right of B'
8759        >>> ex.observe('B', 'down')
8760        3
8761        >>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
8762        >>> # (now-deleted), and the unnamed 'down from B'
8763        >>> ex.allDecisions()
8764        [0, 1, 2, 3]
8765        >>> for tr in ex.allTransitions():
8766        ...     print(tr)
8767        ...
8768        (0, 'right', 1)
8769        (1, 'return', 0)
8770        (1, 'left', 0)
8771        (1, 'right', 2)
8772        (2, 'return', 1)
8773        (1, 'down', 3)
8774        (3, 'return', 1)
8775        >>> # Note transitions from now-deleted nodes, and 'return'
8776        >>> # transitions for unexplored nodes before they get explored
8777        """
8778        seen = set()
8779        result = []
8780        for situation in self:
8781            graph = situation.graph
8782            for (src, dst, transition) in graph.allEdges():  # type:ignore
8783                trans = (src, transition, dst)
8784                if trans not in seen:
8785                    result.append(trans)
8786                    seen.add(trans)
8787
8788        return result

Returns the list of all transitions which existed at any point within the exploration, as 3-tuples with source decision ID, transition name, and destination decision ID. Note that since transitions can be deleted or re-targeted, and a transition name can be re-used after being deleted, things can get messy in the edges cases. When the same transition name is used in different steps with different decision targets, we end up including each possible source-transition-destination triple. Example:

>>> ex = DiscreteExploration()
>>> ex.start('A')
0
>>> ex.observe('A', 'right')
1
>>> ex.explore('right', 'B', 'left')
1
>>> ex.observe('B', 'right')
2
>>> ex.wait()  # leave behind a step where 'B' has a 'right'
>>> ex.primaryDecision(0)
>>> ex.primaryDecision(1)
0
>>> ex.primaryDecision(2)
1
>>> ex.primaryDecision(3)
1
>>> len(ex)
4
>>> ex[3].graph.removeDecision(2)  # delete 'right of B'
>>> ex.observe('B', 'down')
3
>>> # Decisions are: 'A', 'B', and the unnamed 'right of B'
>>> # (now-deleted), and the unnamed 'down from B'
>>> ex.allDecisions()
[0, 1, 2, 3]
>>> for tr in ex.allTransitions():
...     print(tr)
...
(0, 'right', 1)
(1, 'return', 0)
(1, 'left', 0)
(1, 'right', 2)
(2, 'return', 1)
(1, 'down', 3)
(3, 'return', 1)
>>> # Note transitions from now-deleted nodes, and 'return'
>>> # transitions for unexplored nodes before they get explored
def start( self, decision: Union[int, exploration.base.DecisionSpecifier, str], startCapabilities: Optional[exploration.base.CapabilitySet] = None, setMechanismStates: Optional[Dict[int, str]] = None, setCustomState: Optional[dict] = None, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'imposed') -> int:
8790    def start(
8791        self,
8792        decision: base.AnyDecisionSpecifier,
8793        startCapabilities: Optional[base.CapabilitySet] = None,
8794        setMechanismStates: Optional[
8795            Dict[base.MechanismID, base.MechanismState]
8796        ] = None,
8797        setCustomState: Optional[dict] = None,
8798        decisionType: base.DecisionType = "imposed"
8799    ) -> base.DecisionID:
8800        """
8801        Sets the initial position information for a newly-relevant
8802        domain for the current focal context. Creates a new decision
8803        if the decision is specified by name or `DecisionSpecifier` and
8804        that decision doesn't already exist. Returns the decision ID for
8805        the newly-placed decision (or for the specified decision if it
8806        already existed).
8807
8808        Raises a `BadStart` error if the current focal context already
8809        has position information for the specified domain.
8810
8811        - The given `startCapabilities` replaces any existing
8812            capabilities for the current focal context, although you can
8813            leave it as the default `None` to avoid that and retain any
8814            capabilities that have been set up already.
8815        - The given `setMechanismStates` and `setCustomState`
8816            dictionaries override all previous mechanism states & custom
8817            states in the new situation. Leave these as the default
8818            `None` to maintain those states.
8819        - If created, the decision will be placed in the DEFAULT_DOMAIN
8820            domain unless it's specified as a `base.DecisionSpecifier`
8821            with a domain part, in which case that domain is used.
8822        - If specified as a `base.DecisionSpecifier` with a zone part
8823            and a new decision needs to be created, the decision will be
8824            added to that zone, creating it at level 0 if necessary,
8825            although otherwise no zone information will be changed.
8826        - Resets the decision type to "pending" and the action taken to
8827            `None`. Sets the decision type of the previous situation to
8828            'imposed' (or the specified `decisionType`) and sets an
8829            appropriate 'start' action for that situation.
8830        - Tags the step with 'start'.
8831        - Even in a plural- or spreading-focalized domain, you still need
8832            to pick one decision to start at.
8833        """
8834        now = self.getSituation()
8835
8836        startID = now.graph.getDecision(decision)
8837        zone = None
8838        domain = base.DEFAULT_DOMAIN
8839        if startID is None:
8840            if isinstance(decision, base.DecisionID):
8841                raise MissingDecisionError(
8842                    f"Cannot start at decision {decision} because no"
8843                    f" decision with that ID exists. Supply a name or"
8844                    f" DecisionSpecifier if you need the start decision"
8845                    f" to be created automatically."
8846                )
8847            elif isinstance(decision, base.DecisionName):
8848                decision = base.DecisionSpecifier(
8849                    domain=None,
8850                    zone=None,
8851                    name=decision
8852                )
8853            startID = now.graph.addDecision(
8854                decision.name,
8855                domain=decision.domain
8856            )
8857            zone = decision.zone
8858            if decision.domain is not None:
8859                domain = decision.domain
8860
8861        if zone is not None:
8862            if now.graph.getZoneInfo(zone) is None:
8863                now.graph.createZone(zone, 0)
8864            now.graph.addDecisionToZone(startID, zone)
8865
8866        action: base.ExplorationAction = (
8867            'start',
8868            startID,
8869            startID,
8870            domain,
8871            startCapabilities,
8872            setMechanismStates,
8873            setCustomState
8874        )
8875
8876        self.advanceSituation(action, decisionType)
8877
8878        return startID

Sets the initial position information for a newly-relevant domain for the current focal context. Creates a new decision if the decision is specified by name or DecisionSpecifier and that decision doesn't already exist. Returns the decision ID for the newly-placed decision (or for the specified decision if it already existed).

Raises a BadStart error if the current focal context already has position information for the specified domain.

  • The given startCapabilities replaces any existing capabilities for the current focal context, although you can leave it as the default None to avoid that and retain any capabilities that have been set up already.
  • The given setMechanismStates and setCustomState dictionaries override all previous mechanism states & custom states in the new situation. Leave these as the default None to maintain those states.
  • If created, the decision will be placed in the DEFAULT_DOMAIN domain unless it's specified as a base.DecisionSpecifier with a domain part, in which case that domain is used.
  • If specified as a base.DecisionSpecifier with a zone part and a new decision needs to be created, the decision will be added to that zone, creating it at level 0 if necessary, although otherwise no zone information will be changed.
  • Resets the decision type to "pending" and the action taken to None. Sets the decision type of the previous situation to 'imposed' (or the specified decisionType) and sets an appropriate 'start' action for that situation.
  • Tags the step with 'start'.
  • Even in a plural- or spreading-focalized domain, you still need to pick one decision to start at.
def hasBeenVisited( self, decision: Union[int, exploration.base.DecisionSpecifier, str], step: int = -1):
8880    def hasBeenVisited(
8881        self,
8882        decision: base.AnyDecisionSpecifier,
8883        step: int = -1
8884    ):
8885        """
8886        Returns whether or not the specified decision has been visited in
8887        the specified step (default current step).
8888        """
8889        return base.hasBeenVisited(self.getSituation(step), decision)

Returns whether or not the specified decision has been visited in the specified step (default current step).

def setExplorationStatus( self, decision: Union[int, exploration.base.DecisionSpecifier, str], status: Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored'], upgradeOnly: bool = False):
8891    def setExplorationStatus(
8892        self,
8893        decision: base.AnyDecisionSpecifier,
8894        status: base.ExplorationStatus,
8895        upgradeOnly: bool = False
8896    ):
8897        """
8898        Updates the current exploration status of a specific decision in
8899        the current situation. If `upgradeOnly` is true (default is
8900        `False` then the update will only apply if the new exploration
8901        status counts as 'more-explored' than the old one (see
8902        `base.moreExplored`).
8903        """
8904        base.setExplorationStatus(
8905            self.getSituation(),
8906            decision,
8907            status,
8908            upgradeOnly
8909        )

Updates the current exploration status of a specific decision in the current situation. If upgradeOnly is true (default is False then the update will only apply if the new exploration status counts as 'more-explored' than the old one (see base.moreExplored).

def getExplorationStatus( self, decision: Union[int, exploration.base.DecisionSpecifier, str], step: int = -1):
8911    def getExplorationStatus(
8912        self,
8913        decision: base.AnyDecisionSpecifier,
8914        step: int = -1
8915    ):
8916        """
8917        Returns the exploration status of the specified decision at the
8918        specified step (default is last step). Decisions whose
8919        exploration status has never been set will have a default status
8920        of 'unknown'.
8921        """
8922        situation = self.getSituation(step)
8923        dID = situation.graph.resolveDecision(decision)
8924        return situation.state['exploration'].get(dID, 'unknown')

Returns the exploration status of the specified decision at the specified step (default is last step). Decisions whose exploration status has never been set will have a default status of 'unknown'.

def deduceTransitionDetailsAtStep( self, step: int, transition: str, fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto') -> Tuple[Literal['common', 'active'], int, int, Optional[Tuple[Literal['common', 'active'], str, str]]]:
8926    def deduceTransitionDetailsAtStep(
8927        self,
8928        step: int,
8929        transition: base.Transition,
8930        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
8931        whichFocus: Optional[base.FocalPointSpecifier] = None,
8932        inCommon: Union[bool, Literal["auto"]] = "auto"
8933    ) -> Tuple[
8934        base.ContextSpecifier,
8935        base.DecisionID,
8936        base.DecisionID,
8937        Optional[base.FocalPointSpecifier]
8938    ]:
8939        """
8940        Given just a transition name which the player intends to take in
8941        a specific step, deduces the `ContextSpecifier` for which
8942        context should be updated, the source and destination
8943        `DecisionID`s for the transition, and if the destination
8944        decision's domain is plural-focalized, the `FocalPointName`
8945        specifying which focal point should be moved.
8946
8947        Because many of those things are ambiguous, you may get an
8948        `AmbiguousTransitionError` when things are underspecified, and
8949        there are options for specifying some of the extra information
8950        directly:
8951
8952        - `fromDecision` may be used to specify the source decision.
8953        - `whichFocus` may be used to specify the focal point (within a
8954            particular context/domain) being updated. When focal point
8955            ambiguity remains and this is unspecified, the
8956            alphabetically-earliest relevant focal point will be used
8957            (either among all focal points which activate the source
8958            decision, if there are any, or among all focal points for
8959            the entire domain of the destination decision).
8960        - `inCommon` (a `ContextSpecifier`) may be used to specify which
8961            context to update. The default of "auto" will cause the
8962            active context to be selected unless it does not activate
8963            the source decision, in which case the common context will
8964            be selected.
8965
8966        A `MissingDecisionError` will be raised if there are no current
8967        active decisions (e.g., before `start` has been called), and a
8968        `MissingTransitionError` will be raised if the listed transition
8969        does not exist from any active decision (or from the specified
8970        decision if `fromDecision` is used).
8971        """
8972        now = self.getSituation(step)
8973        active = self.getActiveDecisions(step)
8974        if len(active) == 0:
8975            raise MissingDecisionError(
8976                f"There are no active decisions from which transition"
8977                f" {repr(transition)} could be taken at step {step}."
8978            )
8979
8980        # All source/destination decision pairs for transitions with the
8981        # given transition name.
8982        allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {}
8983
8984        # TODO: When should we be trimming the active decisions to match
8985        # any alterations to the graph?
8986        for dID in active:
8987            outgoing = now.graph.destinationsFrom(dID)
8988            if transition in outgoing:
8989                allDecisionPairs[dID] = outgoing[transition]
8990
8991        if len(allDecisionPairs) == 0:
8992            raise MissingTransitionError(
8993                f"No transitions named {repr(transition)} are outgoing"
8994                f" from active decisions at step {step}."
8995                f"\nActive decisions are:"
8996                f"\n{now.graph.namesListing(active)}"
8997            )
8998
8999        if (
9000            fromDecision is not None
9001        and fromDecision not in allDecisionPairs
9002        ):
9003            raise MissingTransitionError(
9004                f"{fromDecision} was specified as the source decision"
9005                f" for traversing transition {repr(transition)} but"
9006                f" there is no transition of that name from that"
9007                f" decision at step {step}."
9008                f"\nValid source decisions are:"
9009                f"\n{now.graph.namesListing(allDecisionPairs)}"
9010            )
9011        elif fromDecision is not None:
9012            fromID = now.graph.resolveDecision(fromDecision)
9013            destID = allDecisionPairs[fromID]
9014            fromDomain = now.graph.domainFor(fromID)
9015        elif len(allDecisionPairs) == 1:
9016            fromID, destID = list(allDecisionPairs.items())[0]
9017            fromDomain = now.graph.domainFor(fromID)
9018        else:
9019            fromID = None
9020            destID = None
9021            fromDomain = None
9022            # Still ambiguous; resolve this below
9023
9024        # Use whichFocus if provided
9025        if whichFocus is not None:
9026            # Type/value check for whichFocus
9027            if (
9028                not isinstance(whichFocus, tuple)
9029             or len(whichFocus) != 3
9030             or whichFocus[0] not in ("active", "common")
9031             or not isinstance(whichFocus[1], base.Domain)
9032             or not isinstance(whichFocus[2], base.FocalPointName)
9033            ):
9034                raise ValueError(
9035                    f"Invalid whichFocus value {repr(whichFocus)}."
9036                    f"\nMust be a length-3 tuple with 'active' or 'common'"
9037                    f" as the first element, a Domain as the second"
9038                    f" element, and a FocalPointName as the third"
9039                    f" element."
9040                )
9041
9042            # Resolve focal point specified
9043            fromID = base.resolvePosition(
9044                now,
9045                whichFocus
9046            )
9047            if fromID is None:
9048                raise MissingTransitionError(
9049                    f"Focal point {repr(whichFocus)} was specified as"
9050                    f" the transition source, but that focal point does"
9051                    f" not have a position."
9052                )
9053            else:
9054                destID = now.graph.destination(fromID, transition)
9055                fromDomain = now.graph.domainFor(fromID)
9056
9057        elif fromID is None:  # whichFocus is None, so it can't disambiguate
9058            raise AmbiguousTransitionError(
9059                f"Transition {repr(transition)} was selected for"
9060                f" disambiguation, but there are multiple transitions"
9061                f" with that name from currently-active decisions, and"
9062                f" neither fromDecision nor whichFocus adequately"
9063                f" disambiguates the specific transition taken."
9064                f"\nValid source decisions at step {step} are:"
9065                f"\n{now.graph.namesListing(allDecisionPairs)}"
9066            )
9067
9068        # At this point, fromID, destID, and fromDomain have
9069        # been resolved.
9070        if fromID is None or destID is None or fromDomain is None:
9071            raise RuntimeError(
9072                f"One of fromID, destID, or fromDomain was None after"
9073                f" disambiguation was finished:"
9074                f"\nfromID: {fromID}, destID: {destID}, fromDomain:"
9075                f" {repr(fromDomain)}"
9076            )
9077
9078        # Now figure out which context activated the source so we know
9079        # which focal point we're moving:
9080        context = self.getActiveContext()
9081        active = base.activeDecisionSet(context)
9082        using: base.ContextSpecifier = "active"
9083        if fromID not in active:
9084            context = self.getCommonContext(step)
9085            using = "common"
9086
9087        destDomain = now.graph.domainFor(destID)
9088        if (
9089            whichFocus is None
9090        and base.getDomainFocalization(context, destDomain) == 'plural'
9091        ):
9092            # Need to figure out which focal point is moving; use the
9093            # alphabetically earliest one that's positioned at the
9094            # fromID, or just the earliest one overall if none of them
9095            # are there.
9096            contextFocalPoints: Dict[
9097                base.FocalPointName,
9098                Optional[base.DecisionID]
9099            ] = cast(
9100                Dict[base.FocalPointName, Optional[base.DecisionID]],
9101                context['activeDecisions'][destDomain]
9102            )
9103            if not isinstance(contextFocalPoints, dict):
9104                raise RuntimeError(
9105                    f"Active decisions specifier for domain"
9106                    f" {repr(destDomain)} with plural focalization has"
9107                    f" a non-dictionary value."
9108                )
9109
9110            if fromDomain == destDomain:
9111                focalCandidates = [
9112                    fp
9113                    for fp, pos in contextFocalPoints.items()
9114                    if pos == fromID
9115                ]
9116            else:
9117                focalCandidates = list(contextFocalPoints)
9118
9119            whichFocus = (using, destDomain, min(focalCandidates))
9120
9121        # Now whichFocus has been set if it wasn't already specified;
9122        # might still be None if it's not relevant.
9123        return (using, fromID, destID, whichFocus)

Given just a transition name which the player intends to take in a specific step, deduces the ContextSpecifier for which context should be updated, the source and destination DecisionIDs for the transition, and if the destination decision's domain is plural-focalized, the FocalPointName specifying which focal point should be moved.

Because many of those things are ambiguous, you may get an AmbiguousTransitionError when things are underspecified, and there are options for specifying some of the extra information directly:

  • fromDecision may be used to specify the source decision.
  • whichFocus may be used to specify the focal point (within a particular context/domain) being updated. When focal point ambiguity remains and this is unspecified, the alphabetically-earliest relevant focal point will be used (either among all focal points which activate the source decision, if there are any, or among all focal points for the entire domain of the destination decision).
  • inCommon (a ContextSpecifier) may be used to specify which context to update. The default of "auto" will cause the active context to be selected unless it does not activate the source decision, in which case the common context will be selected.

A MissingDecisionError will be raised if there are no current active decisions (e.g., before start has been called), and a MissingTransitionError will be raised if the listed transition does not exist from any active decision (or from the specified decision if fromDecision is used).

def advanceSituation( self, action: Union[Tuple[Literal['noAction']], Tuple[Literal['start'], Union[int, Dict[str, int], Set[int]], Optional[int], str, Optional[exploration.base.CapabilitySet], Optional[Dict[int, str]], Optional[dict]], Tuple[Literal['explore'], Literal['common', 'active'], int, Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Optional[str]], Tuple[Literal['take'], Literal['common', 'active'], int, Tuple[str, List[bool]]], Tuple[Literal['take'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]]], Tuple[Literal['warp'], Literal['common', 'active'], int], Tuple[Literal['warp'], Tuple[Literal['common', 'active'], str, str], int], Tuple[Literal['focus'], Literal['common', 'active'], Set[str], Set[str]], Tuple[Literal['swap'], str], Tuple[Literal['focalize'], str], Tuple[Literal['revertTo'], str, Set[str]]], decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> Tuple[exploration.base.Situation, Set[int]]:
9125    def advanceSituation(
9126        self,
9127        action: base.ExplorationAction,
9128        decisionType: base.DecisionType = "active",
9129        challengePolicy: base.ChallengePolicy = "specified"
9130    ) -> Tuple[base.Situation, Set[base.DecisionID]]:
9131        """
9132        Given an `ExplorationAction`, sets that as the action taken in
9133        the current situation, and adds a new situation with the results
9134        of that action. A `DoubleActionError` will be raised if the
9135        current situation already has an action specified, and/or has a
9136        decision type other than 'pending'. By default the type of the
9137        decision will be 'active' but another `DecisionType` can be
9138        specified via the `decisionType` parameter.
9139
9140        If the action specified is `('noAction',)`, then the new
9141        situation will be a copy of the old one; this represents waiting
9142        or being at an ending (a decision type other than 'pending'
9143        should be used).
9144
9145        Although `None` can appear as the action entry in situations
9146        with pending decisions, you cannot call `advanceSituation` with
9147        `None` as the action.
9148
9149        If the action includes taking a transition whose requirements
9150        are not satisfied, the transition will still be taken (and any
9151        consequences applied) but a `TransitionBlockedWarning` will be
9152        issued.
9153
9154        A `ChallengePolicy` may be specified, the default is 'specified'
9155        which requires that outcomes are pre-specified. If any other
9156        policy is set, the challenge outcomes will be reset before
9157        re-resolving them according to the provided policy.
9158
9159        The new situation will have decision type 'pending' and `None`
9160        as the action.
9161
9162        The new situation created as a result of the action is returned,
9163        along with the set of destination decision IDs, including
9164        possibly a modified destination via 'bounce', 'goto', and/or
9165        'follow' effects. For actions that don't have a destination, the
9166        second part of the returned tuple will be an empty set. Multiple
9167        IDs may be in the set when using a start action in a plural- or
9168        spreading-focalized domain, for example.
9169
9170        If the action updates active decisions (including via transition
9171        effects) this will also update the exploration status of those
9172        decisions to 'exploring' if they had been in an unvisited
9173        status (see `updatePosition` and `hasBeenVisited`). This
9174        includes decisions traveled through but not ultimately arrived
9175        at via 'follow' effects.
9176
9177        If any decisions are active in the `ENDINGS_DOMAIN`, attempting
9178        to 'warp', 'explore', 'take', or 'start' will raise an
9179        `InvalidActionError`.
9180        """
9181        now = self.getSituation()
9182        if now.type != 'pending' or now.action is not None:
9183            raise DoubleActionError(
9184                f"Attempted to take action {repr(action)} at step"
9185                f" {len(self) - 1}, but an action and/or decision type"
9186                f" had already been specified:"
9187                f"\nAction: {repr(now.action)}"
9188                f"\nType: {repr(now.type)}"
9189            )
9190
9191        # Update the now situation to add in the decision type and
9192        # action taken:
9193        revised = base.Situation(
9194            now.graph,
9195            now.state,
9196            decisionType,
9197            action,
9198            now.saves,
9199            now.tags,
9200            now.annotations
9201        )
9202        self.situations[-1] = revised
9203
9204        # Separate update process when reverting (this branch returns)
9205        if (
9206            action is not None
9207        and isinstance(action, tuple)
9208        and len(action) == 3
9209        and action[0] == 'revertTo'
9210        and isinstance(action[1], base.SaveSlot)
9211        and isinstance(action[2], set)
9212        and all(isinstance(x, str) for x in action[2])
9213        ):
9214            _, slot, aspects = action
9215            if slot not in now.saves:
9216                raise KeyError(
9217                    f"Cannot load save slot {slot!r} because no save"
9218                    f" data has been established for that slot."
9219                )
9220            load = now.saves[slot]
9221            rGraph, rState = base.revertedState(
9222                (now.graph, now.state),
9223                load,
9224                aspects
9225            )
9226            reverted = base.Situation(
9227                graph=rGraph,
9228                state=rState,
9229                type='pending',
9230                action=None,
9231                saves=copy.deepcopy(now.saves),
9232                tags={},
9233                annotations=[]
9234            )
9235            self.situations.append(reverted)
9236            # Apply any active triggers (edits reverted)
9237            self.applyActiveTriggers()
9238            # Figure out destinations set to return
9239            newDestinations = set()
9240            newPr = rState['primaryDecision']
9241            if newPr is not None:
9242                newDestinations.add(newPr)
9243            return (reverted, newDestinations)
9244
9245        # TODO: These deep copies are expensive time-wise. Can we avoid
9246        # them? Probably not.
9247        newGraph = copy.deepcopy(now.graph)
9248        newState = copy.deepcopy(now.state)
9249        newSaves = copy.copy(now.saves)  # a shallow copy
9250        newTags: Dict[base.Tag, base.TagValue] = {}
9251        newAnnotations: List[base.Annotation] = []
9252        updated = base.Situation(
9253            graph=newGraph,
9254            state=newState,
9255            type='pending',
9256            action=None,
9257            saves=newSaves,
9258            tags=newTags,
9259            annotations=newAnnotations
9260        )
9261
9262        targetContext: base.FocalContext
9263
9264        # Now that action effects have been imprinted into the updated
9265        # situation, append it to our situations list
9266        self.situations.append(updated)
9267
9268        # Figure out effects of the action:
9269        if action is None:
9270            raise InvalidActionError(
9271                "None cannot be used as an action when advancing the"
9272                " situation."
9273            )
9274
9275        aLen = len(action)
9276
9277        destIDs = set()
9278
9279        if (
9280            action[0] in ('start', 'take', 'explore', 'warp')
9281        and any(
9282                newGraph.domainFor(d) == ENDINGS_DOMAIN
9283                for d in self.getActiveDecisions()
9284            )
9285        ):
9286            activeEndings = [
9287                d
9288                for d in self.getActiveDecisions()
9289                if newGraph.domainFor(d) == ENDINGS_DOMAIN
9290            ]
9291            raise InvalidActionError(
9292                f"Attempted to {action[0]!r} while an ending was"
9293                f" active. Active endings are:"
9294                f"\n{newGraph.namesListing(activeEndings)}"
9295            )
9296
9297        if action == ('noAction',):
9298            # No updates needed
9299            pass
9300
9301        elif (
9302            not isinstance(action, tuple)
9303         or (action[0] not in get_args(base.ExplorationActionType))
9304         or not (2 <= aLen <= 7)
9305        ):
9306            raise InvalidActionError(
9307                f"Invalid ExplorationAction tuple (must be a tuple that"
9308                f" starts with an ExplorationActionType and has 2-6"
9309                f" entries if it's not ('noAction',)):"
9310                f"\n{repr(action)}"
9311            )
9312
9313        elif action[0] == 'start':
9314            (
9315                _,
9316                positionSpecifier,
9317                primary,
9318                domain,
9319                capabilities,
9320                mechanismStates,
9321                customState
9322            ) = cast(
9323                Tuple[
9324                    Literal['start'],
9325                    Union[
9326                        base.DecisionID,
9327                        Dict[base.FocalPointName, base.DecisionID],
9328                        Set[base.DecisionID]
9329                    ],
9330                    Optional[base.DecisionID],
9331                    base.Domain,
9332                    Optional[base.CapabilitySet],
9333                    Optional[Dict[base.MechanismID, base.MechanismState]],
9334                    Optional[dict]
9335                ],
9336                action
9337            )
9338            targetContext = newState['contexts'][
9339                newState['activeContext']
9340            ]
9341
9342            targetFocalization = base.getDomainFocalization(
9343                targetContext,
9344                domain
9345            )  # sets up 'singular' as default if
9346
9347            # Check if there are any already-active decisions.
9348            if targetContext['activeDecisions'][domain] is not None:
9349                raise BadStart(
9350                    f"Cannot start in domain {repr(domain)} because"
9351                    f" that domain already has a position. 'start' may"
9352                    f" only be used with domains that don't yet have"
9353                    f" any position information."
9354                )
9355
9356            # Make the domain active
9357            if domain not in targetContext['activeDomains']:
9358                targetContext['activeDomains'].add(domain)
9359
9360            # Check position info matches focalization type and update
9361            # exploration statuses
9362            if isinstance(positionSpecifier, base.DecisionID):
9363                if targetFocalization != 'singular':
9364                    raise BadStart(
9365                        f"Invalid position specifier"
9366                        f" {repr(positionSpecifier)} (type"
9367                        f" {type(positionSpecifier)}). Domain"
9368                        f" {repr(domain)} has {targetFocalization}"
9369                        f" focalization."
9370                    )
9371                base.setExplorationStatus(
9372                    updated,
9373                    positionSpecifier,
9374                    'exploring',
9375                    upgradeOnly=True
9376                )
9377                destIDs.add(positionSpecifier)
9378            elif isinstance(positionSpecifier, dict):
9379                if targetFocalization != 'plural':
9380                    raise BadStart(
9381                        f"Invalid position specifier"
9382                        f" {repr(positionSpecifier)} (type"
9383                        f" {type(positionSpecifier)}). Domain"
9384                        f" {repr(domain)} has {targetFocalization}"
9385                        f" focalization."
9386                    )
9387                destIDs |= set(positionSpecifier.values())
9388            elif isinstance(positionSpecifier, set):
9389                if targetFocalization != 'spreading':
9390                    raise BadStart(
9391                        f"Invalid position specifier"
9392                        f" {repr(positionSpecifier)} (type"
9393                        f" {type(positionSpecifier)}). Domain"
9394                        f" {repr(domain)} has {targetFocalization}"
9395                        f" focalization."
9396                    )
9397                destIDs |= positionSpecifier
9398            else:
9399                raise TypeError(
9400                    f"Invalid position specifier"
9401                    f" {repr(positionSpecifier)} (type"
9402                    f" {type(positionSpecifier)}). It must be a"
9403                    f" DecisionID, a dictionary from FocalPointNames to"
9404                    f" DecisionIDs, or a set of DecisionIDs, according"
9405                    f" to the focalization of the relevant domain."
9406                )
9407
9408            # Put specified position(s) in place
9409            # TODO: This cast is really silly...
9410            targetContext['activeDecisions'][domain] = cast(
9411                Union[
9412                    None,
9413                    base.DecisionID,
9414                    Dict[base.FocalPointName, Optional[base.DecisionID]],
9415                    Set[base.DecisionID]
9416                ],
9417                positionSpecifier
9418            )
9419
9420            # Set primary decision
9421            newState['primaryDecision'] = primary
9422
9423            # Set capabilities
9424            if capabilities is not None:
9425                targetContext['capabilities'] = capabilities
9426
9427            # Set mechanism states
9428            if mechanismStates is not None:
9429                newState['mechanisms'] = mechanismStates
9430
9431            # Set custom state
9432            if customState is not None:
9433                newState['custom'] = customState
9434
9435        elif action[0] in ('explore', 'take', 'warp'):  # similar handling
9436            assert (
9437                len(action) == 3
9438             or len(action) == 4
9439             or len(action) == 6
9440             or len(action) == 7
9441            )
9442            # Set up necessary variables
9443            cSpec: base.ContextSpecifier = "active"
9444            fromID: Optional[base.DecisionID] = None
9445            takeTransition: Optional[base.Transition] = None
9446            outcomes: List[bool] = []
9447            destID: base.DecisionID  # No starting value as it's not optional
9448            moveInDomain: Optional[base.Domain] = None
9449            moveWhich: Optional[base.FocalPointName] = None
9450
9451            # Figure out target context
9452            if isinstance(action[1], str):
9453                if action[1] not in get_args(base.ContextSpecifier):
9454                    raise InvalidActionError(
9455                        f"Action specifies {repr(action[1])} context,"
9456                        f" but that's not a valid context specifier."
9457                        f" The valid options are:"
9458                        f"\n{repr(get_args(base.ContextSpecifier))}"
9459                    )
9460                else:
9461                    cSpec = cast(base.ContextSpecifier, action[1])
9462            else:  # Must be a `FocalPointSpecifier`
9463                cSpec, moveInDomain, moveWhich = cast(
9464                    base.FocalPointSpecifier,
9465                    action[1]
9466                )
9467                assert moveInDomain is not None
9468
9469            # Grab target context to work in
9470            if cSpec == 'common':
9471                targetContext = newState['common']
9472            else:
9473                targetContext = newState['contexts'][
9474                    newState['activeContext']
9475                ]
9476
9477            # Check focalization of the target domain
9478            if moveInDomain is not None:
9479                fType = base.getDomainFocalization(
9480                    targetContext,
9481                    moveInDomain
9482                )
9483                if (
9484                    (
9485                        isinstance(action[1], str)
9486                    and fType == 'plural'
9487                    ) or (
9488                        not isinstance(action[1], str)
9489                    and fType != 'plural'
9490                    )
9491                ):
9492                    raise ImpossibleActionError(
9493                        f"Invalid ExplorationAction (moves in"
9494                        f" plural-focalized domains must include a"
9495                        f" FocalPointSpecifier, while moves in"
9496                        f" non-plural-focalized domains must not."
9497                        f" Domain {repr(moveInDomain)} is"
9498                        f" {fType}-focalized):"
9499                        f"\n{repr(action)}"
9500                    )
9501
9502            if action[0] == "warp":
9503                # It's a warp, so destination is specified directly
9504                if not isinstance(action[2], base.DecisionID):
9505                    raise TypeError(
9506                        f"Invalid ExplorationAction tuple (third part"
9507                        f" must be a decision ID for 'warp' actions):"
9508                        f"\n{repr(action)}"
9509                    )
9510                else:
9511                    destID = cast(base.DecisionID, action[2])
9512
9513            elif aLen == 4 or aLen == 7:
9514                # direct 'take' or 'explore'
9515                fromID = cast(base.DecisionID, action[2])
9516                takeTransition, outcomes = cast(
9517                    base.TransitionWithOutcomes,
9518                    action[3]  # type: ignore [misc]
9519                )
9520                if (
9521                    not isinstance(fromID, base.DecisionID)
9522                 or not isinstance(takeTransition, base.Transition)
9523                ):
9524                    raise InvalidActionError(
9525                        f"Invalid ExplorationAction tuple (for 'take' or"
9526                        f" 'explore', if the length is 4/7, parts 2-4"
9527                        f" must be a context specifier, a decision ID, and a"
9528                        f" transition name. Got:"
9529                        f"\n{repr(action)}"
9530                    )
9531
9532                try:
9533                    destID = newGraph.destination(fromID, takeTransition)
9534                except MissingDecisionError:
9535                    raise ImpossibleActionError(
9536                        f"Invalid ExplorationAction: move from decision"
9537                        f" {fromID} is invalid because there is no"
9538                        f" decision with that ID in the current"
9539                        f" graph."
9540                        f"\nValid decisions are:"
9541                        f"\n{newGraph.namesListing(newGraph)}"
9542                    )
9543                except MissingTransitionError:
9544                    valid = newGraph.destinationsFrom(fromID)
9545                    listing = newGraph.destinationsListing(valid)
9546                    raise ImpossibleActionError(
9547                        f"Invalid ExplorationAction: move from decision"
9548                        f" {newGraph.identityOf(fromID)}"
9549                        f" along transition {repr(takeTransition)} is"
9550                        f" invalid because there is no such transition"
9551                        f" at that decision."
9552                        f"\nValid transitions there are:"
9553                        f"\n{listing}"
9554                    )
9555                targetActive = targetContext['activeDecisions']
9556                if moveInDomain is not None:
9557                    activeInDomain = targetActive[moveInDomain]
9558                    if (
9559                        (
9560                            isinstance(activeInDomain, base.DecisionID)
9561                        and fromID != activeInDomain
9562                        )
9563                     or (
9564                            isinstance(activeInDomain, set)
9565                        and fromID not in activeInDomain
9566                        )
9567                     or (
9568                            isinstance(activeInDomain, dict)
9569                        and fromID not in activeInDomain.values()
9570                        )
9571                    ):
9572                        raise ImpossibleActionError(
9573                            f"Invalid ExplorationAction: move from"
9574                            f" decision {fromID} is invalid because"
9575                            f" that decision is not active in domain"
9576                            f" {repr(moveInDomain)} in the current"
9577                            f" graph."
9578                            f"\nValid decisions are:"
9579                            f"\n{newGraph.namesListing(newGraph)}"
9580                        )
9581
9582            elif aLen == 3 or aLen == 6:
9583                # 'take' or 'explore' focal point
9584                # We know that moveInDomain is not None here.
9585                assert moveInDomain is not None
9586                if not isinstance(action[2], base.Transition):
9587                    raise InvalidActionError(
9588                        f"Invalid ExplorationAction tuple (for 'take'"
9589                        f" actions if the second part is a"
9590                        f" FocalPointSpecifier the third part must be a"
9591                        f" transition name):"
9592                        f"\n{repr(action)}"
9593                    )
9594
9595                takeTransition, outcomes = cast(
9596                    base.TransitionWithOutcomes,
9597                    action[2]
9598                )
9599                targetActive = targetContext['activeDecisions']
9600                activeInDomain = cast(
9601                    Dict[base.FocalPointName, Optional[base.DecisionID]],
9602                    targetActive[moveInDomain]
9603                )
9604                if (
9605                    moveInDomain is not None
9606                and (
9607                        not isinstance(activeInDomain, dict)
9608                     or moveWhich not in activeInDomain
9609                    )
9610                ):
9611                    raise ImpossibleActionError(
9612                        f"Invalid ExplorationAction: move of focal"
9613                        f" point {repr(moveWhich)} in domain"
9614                        f" {repr(moveInDomain)} is invalid because"
9615                        f" that domain does not have a focal point"
9616                        f" with that name."
9617                    )
9618                fromID = activeInDomain[moveWhich]
9619                if fromID is None:
9620                    raise ImpossibleActionError(
9621                        f"Invalid ExplorationAction: move of focal"
9622                        f" point {repr(moveWhich)} in domain"
9623                        f" {repr(moveInDomain)} is invalid because"
9624                        f" that focal point does not have a position"
9625                        f" at this step."
9626                    )
9627                try:
9628                    destID = newGraph.destination(fromID, takeTransition)
9629                except MissingDecisionError:
9630                    raise ImpossibleActionError(
9631                        f"Invalid exploration state: focal point"
9632                        f" {repr(moveWhich)} in domain"
9633                        f" {repr(moveInDomain)} specifies decision"
9634                        f" {fromID} as the current position, but"
9635                        f" that decision does not exist!"
9636                    )
9637                except MissingTransitionError:
9638                    valid = newGraph.destinationsFrom(fromID)
9639                    listing = newGraph.destinationsListing(valid)
9640                    raise ImpossibleActionError(
9641                        f"Invalid ExplorationAction: move of focal"
9642                        f" point {repr(moveWhich)} in domain"
9643                        f" {repr(moveInDomain)} along transition"
9644                        f" {repr(takeTransition)} is invalid because"
9645                        f" that focal point is at decision"
9646                        f" {newGraph.identityOf(fromID)} and that"
9647                        f" decision does not have an outgoing"
9648                        f" transition with that name.\nValid"
9649                        f" transitions from that decision are:"
9650                        f"\n{listing}"
9651                    )
9652
9653            else:
9654                raise InvalidActionError(
9655                    f"Invalid ExplorationAction: unrecognized"
9656                    f" 'explore', 'take' or 'warp' format:"
9657                    f"\n{action}"
9658                )
9659
9660            # If we're exploring, update information for the destination
9661            if action[0] == 'explore':
9662                zone = cast(Optional[base.Zone], action[-1])
9663                recipName = cast(Optional[base.Transition], action[-2])
9664                destOrName = cast(
9665                    Union[base.DecisionName, base.DecisionID, None],
9666                    action[-3]
9667                )
9668                if isinstance(destOrName, base.DecisionID):
9669                    destID = destOrName
9670
9671                if fromID is None or takeTransition is None:
9672                    raise ImpossibleActionError(
9673                        f"Invalid ExplorationAction: exploration"
9674                        f" has unclear origin decision or transition."
9675                        f" Got:\n{action}"
9676                    )
9677
9678                currentDest = newGraph.destination(fromID, takeTransition)
9679                if not newGraph.isConfirmed(currentDest):
9680                    newGraph.replaceUnconfirmed(
9681                        fromID,
9682                        takeTransition,
9683                        destOrName,
9684                        recipName,
9685                        placeInZone=zone,
9686                        forceNew=not isinstance(destOrName, base.DecisionID)
9687                    )
9688                else:
9689                    # Otherwise, since the destination already existed
9690                    # and was hooked up at the right decision, no graph
9691                    # edits need to be made, unless we need to rename
9692                    # the reciprocal.
9693                    # TODO: Do we care about zones here?
9694                    if recipName is not None:
9695                        oldReciprocal = newGraph.getReciprocal(
9696                            fromID,
9697                            takeTransition
9698                        )
9699                        if (
9700                            oldReciprocal is not None
9701                        and oldReciprocal != recipName
9702                        ):
9703                            newGraph.addTransition(
9704                                destID,
9705                                recipName,
9706                                fromID,
9707                                None
9708                            )
9709                            newGraph.setReciprocal(
9710                                destID,
9711                                recipName,
9712                                takeTransition,
9713                                setBoth=True
9714                            )
9715                            newGraph.mergeTransitions(
9716                                destID,
9717                                oldReciprocal,
9718                                recipName
9719                            )
9720
9721            # If we are moving along a transition, check requirements
9722            # and apply transition effects *before* updating our
9723            # position, and check that they don't cancel the normal
9724            # position update
9725            finalDest = None
9726            if takeTransition is not None:
9727                assert fromID is not None  # both or neither
9728                if not self.isTraversable(fromID, takeTransition):
9729                    req = now.graph.getTransitionRequirement(
9730                        fromID,
9731                        takeTransition
9732                    )
9733                    # TODO: Alter warning message if transition is
9734                    # deactivated vs. requirement not satisfied
9735                    warnings.warn(
9736                        (
9737                            f"The requirements for transition"
9738                            f" {takeTransition!r} from decision"
9739                            f" {now.graph.identityOf(fromID)} are"
9740                            f" not met at step {len(self) - 1} (or that"
9741                            f" transition has been deactivated):\n{req}"
9742                        ),
9743                        TransitionBlockedWarning
9744                    )
9745
9746                # Apply transition consequences to our new state and
9747                # figure out if we need to skip our normal update or not
9748                finalDest = self.applyTransitionConsequence(
9749                    fromID,
9750                    (takeTransition, outcomes),
9751                    moveWhich,
9752                    challengePolicy
9753                )
9754
9755            # Check moveInDomain
9756            destDomain = newGraph.domainFor(destID)
9757            if moveInDomain is not None and moveInDomain != destDomain:
9758                raise ImpossibleActionError(
9759                    f"Invalid ExplorationAction: move specified"
9760                    f" domain {repr(moveInDomain)} as the domain of"
9761                    f" the focal point to move, but the destination"
9762                    f" of the move is {now.graph.identityOf(destID)}"
9763                    f" which is in domain {repr(destDomain)}, so focal"
9764                    f" point {repr(moveWhich)} cannot be moved there."
9765                )
9766
9767            # Now that we know where we're going, update position
9768            # information (assuming it wasn't already set):
9769            if finalDest is None:
9770                finalDest = destID
9771                base.updatePosition(
9772                    updated,
9773                    destID,
9774                    cSpec,
9775                    moveWhich
9776                )
9777
9778            destIDs.add(finalDest)
9779
9780        elif action[0] == "focus":
9781            # Figure out target context
9782            action = cast(
9783                Tuple[
9784                    Literal['focus'],
9785                    base.ContextSpecifier,
9786                    Set[base.Domain],
9787                    Set[base.Domain]
9788                ],
9789                action
9790            )
9791            contextSpecifier: base.ContextSpecifier = action[1]
9792            if contextSpecifier == 'common':
9793                targetContext = newState['common']
9794            else:
9795                targetContext = newState['contexts'][
9796                    newState['activeContext']
9797                ]
9798
9799            # Just need to swap out active domains
9800            goingOut, comingIn = cast(
9801                Tuple[Set[base.Domain], Set[base.Domain]],
9802                action[2:]
9803            )
9804            if (
9805                not isinstance(goingOut, set)
9806             or not isinstance(comingIn, set)
9807             or not all(isinstance(d, base.Domain) for d in goingOut)
9808             or not all(isinstance(d, base.Domain) for d in comingIn)
9809            ):
9810                raise InvalidActionError(
9811                    f"Invalid ExplorationAction tuple (must have 4"
9812                    f" parts if the first part is 'focus' and"
9813                    f" the third and fourth parts must be sets of"
9814                    f" domains):"
9815                    f"\n{repr(action)}"
9816                )
9817            activeSet = targetContext['activeDomains']
9818            for dom in goingOut:
9819                try:
9820                    activeSet.remove(dom)
9821                except KeyError:
9822                    warnings.warn(
9823                        (
9824                            f"Domain {repr(dom)} was deactivated at"
9825                            f" step {len(self)} but it was already"
9826                            f" inactive at that point."
9827                        ),
9828                        InactiveDomainWarning
9829                    )
9830            # TODO: Also warn for doubly-activated domains?
9831            activeSet |= comingIn
9832
9833            # destIDs remains empty in this case
9834
9835        elif action[0] == 'swap':  # update which `FocalContext` is active
9836            newContext = cast(base.FocalContextName, action[1])
9837            if newContext not in newState['contexts']:
9838                raise MissingFocalContextError(
9839                    f"'swap' action with target {repr(newContext)} is"
9840                    f" invalid because no context with that name"
9841                    f" exists."
9842                )
9843            newState['activeContext'] = newContext
9844
9845            # destIDs remains empty in this case
9846
9847        elif action[0] == 'focalize':  # create new `FocalContext`
9848            newContext = cast(base.FocalContextName, action[1])
9849            if newContext in newState['contexts']:
9850                raise FocalContextCollisionError(
9851                    f"'focalize' action with target {repr(newContext)}"
9852                    f" is invalid because a context with that name"
9853                    f" already exists."
9854                )
9855            newState['contexts'][newContext] = base.emptyFocalContext()
9856            newState['activeContext'] = newContext
9857
9858            # destIDs remains empty in this case
9859
9860        # revertTo is handled above
9861        else:
9862            raise InvalidActionError(
9863                f"Invalid ExplorationAction tuple (first item must be"
9864                f" an ExplorationActionType, and tuple must be length-1"
9865                f" if the action type is 'noAction'):"
9866                f"\n{repr(action)}"
9867            )
9868
9869        # Apply any active triggers
9870        followTo = self.applyActiveTriggers()
9871        if followTo is not None:
9872            destIDs.add(followTo)
9873            # TODO: Re-work to work with multiple position updates in
9874            # different focal contexts, domains, and/or for different
9875            # focal points in plural-focalized domains.
9876
9877        return (updated, destIDs)

Given an ExplorationAction, sets that as the action taken in the current situation, and adds a new situation with the results of that action. A DoubleActionError will be raised if the current situation already has an action specified, and/or has a decision type other than 'pending'. By default the type of the decision will be 'active' but another DecisionType can be specified via the decisionType parameter.

If the action specified is ('noAction',), then the new situation will be a copy of the old one; this represents waiting or being at an ending (a decision type other than 'pending' should be used).

Although None can appear as the action entry in situations with pending decisions, you cannot call advanceSituation with None as the action.

If the action includes taking a transition whose requirements are not satisfied, the transition will still be taken (and any consequences applied) but a TransitionBlockedWarning will be issued.

A ChallengePolicy may be specified, the default is 'specified' which requires that outcomes are pre-specified. If any other policy is set, the challenge outcomes will be reset before re-resolving them according to the provided policy.

The new situation will have decision type 'pending' and None as the action.

The new situation created as a result of the action is returned, along with the set of destination decision IDs, including possibly a modified destination via 'bounce', 'goto', and/or 'follow' effects. For actions that don't have a destination, the second part of the returned tuple will be an empty set. Multiple IDs may be in the set when using a start action in a plural- or spreading-focalized domain, for example.

If the action updates active decisions (including via transition effects) this will also update the exploration status of those decisions to 'exploring' if they had been in an unvisited status (see updatePosition and hasBeenVisited). This includes decisions traveled through but not ultimately arrived at via 'follow' effects.

If any decisions are active in the ENDINGS_DOMAIN, attempting to 'warp', 'explore', 'take', or 'start' will raise an InvalidActionError.

def applyActiveTriggers(self) -> Optional[int]:
9879    def applyActiveTriggers(self) -> Optional[base.DecisionID]:
9880        """
9881        Finds all actions with the 'trigger' tag attached to currently
9882        active decisions, and applies their effects if their requirements
9883        are met (ordered by decision-ID with ties broken alphabetically
9884        by action name).
9885
9886        'bounce', 'goto' and 'follow' effects may apply. However, any
9887        new triggers that would be activated because of decisions
9888        reached by such effects will not apply. Note that 'bounce'
9889        effects update position to the decision where the action was
9890        attached, which is usually a no-op. This function returns the
9891        decision ID of the decision reached by the last decision-moving
9892        effect applied, or `None` if no such effects triggered.
9893
9894        TODO: What about situations where positions are updated in
9895        multiple domains or multiple foal points in a plural domain are
9896        independently updated?
9897
9898        TODO: Tests for this!
9899        """
9900        active = self.getActiveDecisions()
9901        now = self.getSituation()
9902        graph = now.graph
9903        finalFollow = None
9904        for decision in sorted(active):
9905            for action in graph.decisionActions(decision):
9906                if (
9907                    'trigger' in graph.transitionTags(decision, action)
9908                and self.isTraversable(decision, action)
9909                ):
9910                    followTo = self.applyTransitionConsequence(
9911                        decision,
9912                        action
9913                    )
9914                    if followTo is not None:
9915                        # TODO: How will triggers interact with
9916                        # plural-focalized domains? Probably need to fix
9917                        # this to detect moveWhich based on which focal
9918                        # points are at the decision where the transition
9919                        # is, and then apply this to each of them?
9920                        base.updatePosition(now, followTo)
9921                        finalFollow = followTo
9922
9923        return finalFollow

Finds all actions with the 'trigger' tag attached to currently active decisions, and applies their effects if their requirements are met (ordered by decision-ID with ties broken alphabetically by action name).

'bounce', 'goto' and 'follow' effects may apply. However, any new triggers that would be activated because of decisions reached by such effects will not apply. Note that 'bounce' effects update position to the decision where the action was attached, which is usually a no-op. This function returns the decision ID of the decision reached by the last decision-moving effect applied, or None if no such effects triggered.

TODO: What about situations where positions are updated in multiple domains or multiple foal points in a plural domain are independently updated?

TODO: Tests for this!

def explore( self, transition: Union[str, Tuple[str, List[bool]]], destination: Union[str, int, NoneType], reciprocal: Optional[str] = None, zone: Optional[str] = '', fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto', decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
 9925    def explore(
 9926        self,
 9927        transition: base.AnyTransition,
 9928        destination: Union[base.DecisionName, base.DecisionID, None],
 9929        reciprocal: Optional[base.Transition] = None,
 9930        zone: Optional[base.Zone] = base.DefaultZone,
 9931        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9932        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9933        inCommon: Union[bool, Literal["auto"]] = "auto",
 9934        decisionType: base.DecisionType = "active",
 9935        challengePolicy: base.ChallengePolicy = "specified"
 9936    ) -> base.DecisionID:
 9937        """
 9938        Adds a new situation to the exploration representing the
 9939        traversal of the specified transition (possibly with outcomes
 9940        specified for challenges among that transitions consequences).
 9941        Uses `deduceTransitionDetailsAtStep` to figure out from the
 9942        transition name which specific transition is taken (and which
 9943        focal point is updated if necessary). This uses the
 9944        `fromDecision`, `whichFocus`, and `inCommon` optional
 9945        parameters, and also determines whether to update the common or
 9946        the active `FocalContext`. Sets the exploration status of the
 9947        decision explored to 'exploring'. Returns the decision ID for
 9948        the destination reached, accounting for goto/bounce/follow
 9949        effects that might have triggered.
 9950
 9951        The `destination` will be used to name the newly-explored
 9952        decision, except when it's a `DecisionID`, in which case that
 9953        decision must be unvisited, and we'll connect the specified
 9954        transition to that decision.
 9955
 9956        The focalization of the destination domain in the context to be
 9957        updated determines how active decisions are changed:
 9958
 9959        - If the destination domain is focalized as 'single', then in
 9960            the subsequent `Situation`, the destination decision will
 9961            become the single active decision in that domain.
 9962        - If it's focalized as 'plural', then one of the
 9963            `FocalPointName`s for that domain will be moved to activate
 9964            that decision; which one can be specified using `whichFocus`
 9965            or if left unspecified, will be deduced: if the starting
 9966            decision is in the same domain, then the
 9967            alphabetically-earliest focal point which is at the starting
 9968            decision will be moved. If the starting position is in a
 9969            different domain, then the alphabetically earliest focal
 9970            point among all focal points in the destination domain will
 9971            be moved.
 9972        - If it's focalized as 'spreading', then the destination
 9973            decision will be added to the set of active decisions in
 9974            that domain, without removing any.
 9975
 9976        The transition named must have been pointing to an unvisited
 9977        decision (see `hasBeenVisited`), and the name of that decision
 9978        will be updated if a `destination` value is given (a
 9979        `DecisionCollisionWarning` will be issued if the destination
 9980        name is a duplicate of another name in the graph, although this
 9981        is not an error). Additionally:
 9982
 9983        - If a `reciprocal` name is specified, the reciprocal transition
 9984            will be renamed using that name, or created with that name if
 9985            it didn't already exist. If reciprocal is left as `None` (the
 9986            default) then no change will be made to the reciprocal
 9987            transition, and it will not be created if it doesn't exist.
 9988        - If a `zone` is specified, the newly-explored decision will be
 9989            added to that zone (and that zone will be created at level 0
 9990            if it didn't already exist). If `zone` is set to `None` then
 9991            it will not be added to any new zones. If `zone` is left as
 9992            the default (the `base.DefaultZone` value) then the explored
 9993            decision will be added to each zone that the decision it was
 9994            explored from is a part of. If a zone needs to be created,
 9995            that zone will be added as a sub-zone of each zone which is a
 9996            parent of a zone that directly contains the origin decision.
 9997        - An `ExplorationStatusError` will be raised if the specified
 9998            transition leads to a decision whose `ExplorationStatus` is
 9999            'exploring' or higher (i.e., `hasBeenVisited`). (Use
10000            `returnTo` instead to adjust things when a transition to an
10001            unknown destination turns out to lead to an already-known
10002            destination.)
10003        - A `TransitionBlockedWarning` will be issued if the specified
10004            transition is not traversable given the current game state
10005            (but in that last case the step will still be taken).
10006        - By default, the decision type for the new step will be
10007            'active', but a `decisionType` value can be specified to
10008            override that.
10009        - By default, the 'mostLikely' `ChallengePolicy` will be used to
10010            resolve challenges in the consequence of the transition
10011            taken, but an alternate policy can be supplied using the
10012            `challengePolicy` argument.
10013        """
10014        now = self.getSituation()
10015
10016        transitionName, outcomes = base.nameAndOutcomes(transition)
10017
10018        # Deduce transition details from the name + optional specifiers
10019        (
10020            using,
10021            fromID,
10022            destID,
10023            whichFocus
10024        ) = self.deduceTransitionDetailsAtStep(
10025            -1,
10026            transitionName,
10027            fromDecision,
10028            whichFocus,
10029            inCommon
10030        )
10031
10032        # Issue a warning if the destination name is already in use
10033        if destination is not None:
10034            if isinstance(destination, base.DecisionName):
10035                try:
10036                    existingID = now.graph.resolveDecision(destination)
10037                    collision = existingID != destID
10038                except MissingDecisionError:
10039                    collision = False
10040                except AmbiguousDecisionSpecifierError:
10041                    collision = True
10042
10043                if collision and WARN_OF_NAME_COLLISIONS:
10044                    warnings.warn(
10045                        (
10046                            f"The destination name {repr(destination)} is"
10047                            f" already in use when exploring transition"
10048                            f" {repr(transition)} from decision"
10049                            f" {now.graph.identityOf(fromID)} at step"
10050                            f" {len(self) - 1}."
10051                        ),
10052                        DecisionCollisionWarning
10053                    )
10054
10055        # TODO: Different terminology for "exploration state above
10056        # noticed" vs. "DG thinks it's been visited"...
10057        if (
10058            self.hasBeenVisited(destID)
10059        ):
10060            raise ExplorationStatusError(
10061                f"Cannot explore to decision"
10062                f" {now.graph.identityOf(destID)} because it has"
10063                f" already been visited. Use returnTo instead of"
10064                f" explore when discovering a connection back to a"
10065                f" previously-explored decision."
10066            )
10067
10068        if (
10069            isinstance(destination, base.DecisionID)
10070        and self.hasBeenVisited(destination)
10071        ):
10072            raise ExplorationStatusError(
10073                f"Cannot explore to decision"
10074                f" {now.graph.identityOf(destination)} because it has"
10075                f" already been visited. Use returnTo instead of"
10076                f" explore when discovering a connection back to a"
10077                f" previously-explored decision."
10078            )
10079
10080        actionTaken: base.ExplorationAction = (
10081            'explore',
10082            using,
10083            fromID,
10084            (transitionName, outcomes),
10085            destination,
10086            reciprocal,
10087            zone
10088        )
10089        if whichFocus is not None:
10090            # A move-from-specific-focal-point action
10091            actionTaken = (
10092                'explore',
10093                whichFocus,
10094                (transitionName, outcomes),
10095                destination,
10096                reciprocal,
10097                zone
10098            )
10099
10100        # Advance the situation, applying transition effects and
10101        # updating the destination decision.
10102        _, finalDest = self.advanceSituation(
10103            actionTaken,
10104            decisionType,
10105            challengePolicy
10106        )
10107
10108        # TODO: Is this assertion always valid?
10109        assert len(finalDest) == 1
10110        return next(x for x in finalDest)

Adds a new situation to the exploration representing the traversal of the specified transition (possibly with outcomes specified for challenges among that transitions consequences). Uses deduceTransitionDetailsAtStep to figure out from the transition name which specific transition is taken (and which focal point is updated if necessary). This uses the fromDecision, whichFocus, and inCommon optional parameters, and also determines whether to update the common or the active FocalContext. Sets the exploration status of the decision explored to 'exploring'. Returns the decision ID for the destination reached, accounting for goto/bounce/follow effects that might have triggered.

The destination will be used to name the newly-explored decision, except when it's a DecisionID, in which case that decision must be unvisited, and we'll connect the specified transition to that decision.

The focalization of the destination domain in the context to be updated determines how active decisions are changed:

  • If the destination domain is focalized as 'single', then in the subsequent Situation, the destination decision will become the single active decision in that domain.
  • If it's focalized as 'plural', then one of the FocalPointNames for that domain will be moved to activate that decision; which one can be specified using whichFocus or if left unspecified, will be deduced: if the starting decision is in the same domain, then the alphabetically-earliest focal point which is at the starting decision will be moved. If the starting position is in a different domain, then the alphabetically earliest focal point among all focal points in the destination domain will be moved.
  • If it's focalized as 'spreading', then the destination decision will be added to the set of active decisions in that domain, without removing any.

The transition named must have been pointing to an unvisited decision (see hasBeenVisited), and the name of that decision will be updated if a destination value is given (a DecisionCollisionWarning will be issued if the destination name is a duplicate of another name in the graph, although this is not an error). Additionally:

  • If a reciprocal name is specified, the reciprocal transition will be renamed using that name, or created with that name if it didn't already exist. If reciprocal is left as None (the default) then no change will be made to the reciprocal transition, and it will not be created if it doesn't exist.
  • If a zone is specified, the newly-explored decision will be added to that zone (and that zone will be created at level 0 if it didn't already exist). If zone is set to None then it will not be added to any new zones. If zone is left as the default (the base.DefaultZone value) then the explored decision will be added to each zone that the decision it was explored from is a part of. If a zone needs to be created, that zone will be added as a sub-zone of each zone which is a parent of a zone that directly contains the origin decision.
  • An ExplorationStatusError will be raised if the specified transition leads to a decision whose ExplorationStatus is 'exploring' or higher (i.e., hasBeenVisited). (Use returnTo instead to adjust things when a transition to an unknown destination turns out to lead to an already-known destination.)
  • A TransitionBlockedWarning will be issued if the specified transition is not traversable given the current game state (but in that last case the step will still be taken).
  • By default, the decision type for the new step will be 'active', but a decisionType value can be specified to override that.
  • By default, the 'mostLikely' ChallengePolicy will be used to resolve challenges in the consequence of the transition taken, but an alternate policy can be supplied using the challengePolicy argument.
def returnTo( self, transition: Union[str, Tuple[str, List[bool]]], destination: Union[int, exploration.base.DecisionSpecifier, str], reciprocal: Optional[str] = None, fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto', decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
10112    def returnTo(
10113        self,
10114        transition: base.AnyTransition,
10115        destination: base.AnyDecisionSpecifier,
10116        reciprocal: Optional[base.Transition] = None,
10117        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10118        whichFocus: Optional[base.FocalPointSpecifier] = None,
10119        inCommon: Union[bool, Literal["auto"]] = "auto",
10120        decisionType: base.DecisionType = "active",
10121        challengePolicy: base.ChallengePolicy = "specified"
10122    ) -> base.DecisionID:
10123        """
10124        Adds a new graph to the exploration that replaces the given
10125        transition at the current position (which must lead to an unknown
10126        node, or a `MissingDecisionError` will result). The new
10127        transition will connect back to the specified destination, which
10128        must already exist (or a different `ValueError` will be raised).
10129        Returns the decision ID for the destination reached.
10130
10131        Deduces transition details using the optional `fromDecision`,
10132        `whichFocus`, and `inCommon` arguments in addition to the
10133        `transition` value; see `deduceTransitionDetailsAtStep`.
10134
10135        If a `reciprocal` transition is specified, that transition must
10136        either not already exist in the destination decision or lead to
10137        an unknown region; it will be replaced (or added) as an edge
10138        leading back to the current position.
10139
10140        The `decisionType` and `challengePolicy` optional arguments are
10141        used for `advanceSituation`.
10142
10143        A `TransitionBlockedWarning` will be issued if the requirements
10144        for the transition are not met, but the step will still be taken.
10145        Raises a `MissingDecisionError` if there is no current
10146        transition.
10147        """
10148        now = self.getSituation()
10149
10150        transitionName, outcomes = base.nameAndOutcomes(transition)
10151
10152        # Deduce transition details from the name + optional specifiers
10153        (
10154            using,
10155            fromID,
10156            destID,
10157            whichFocus
10158        ) = self.deduceTransitionDetailsAtStep(
10159            -1,
10160            transitionName,
10161            fromDecision,
10162            whichFocus,
10163            inCommon
10164        )
10165
10166        # Replace with connection to existing destination
10167        destID = now.graph.resolveDecision(destination)
10168        if not self.hasBeenVisited(destID):
10169            raise ExplorationStatusError(
10170                f"Cannot return to decision"
10171                f" {now.graph.identityOf(destID)} because it has NOT"
10172                f" already been at least partially explored. Use"
10173                f" explore instead of returnTo when discovering a"
10174                f" connection to a previously-unexplored decision."
10175            )
10176
10177        now.graph.replaceUnconfirmed(
10178            fromID,
10179            transitionName,
10180            destID,
10181            reciprocal
10182        )
10183
10184        # A move-from-decision action
10185        actionTaken: base.ExplorationAction = (
10186            'take',
10187            using,
10188            fromID,
10189            (transitionName, outcomes)
10190        )
10191        if whichFocus is not None:
10192            # A move-from-specific-focal-point action
10193            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10194
10195        # Next, advance the situation, applying transition effects
10196        _, finalDest = self.advanceSituation(
10197            actionTaken,
10198            decisionType,
10199            challengePolicy
10200        )
10201
10202        assert len(finalDest) == 1
10203        return next(x for x in finalDest)

Adds a new graph to the exploration that replaces the given transition at the current position (which must lead to an unknown node, or a MissingDecisionError will result). The new transition will connect back to the specified destination, which must already exist (or a different ValueError will be raised). Returns the decision ID for the destination reached.

Deduces transition details using the optional fromDecision, whichFocus, and inCommon arguments in addition to the transition value; see deduceTransitionDetailsAtStep.

If a reciprocal transition is specified, that transition must either not already exist in the destination decision or lead to an unknown region; it will be replaced (or added) as an edge leading back to the current position.

The decisionType and challengePolicy optional arguments are used for advanceSituation.

A TransitionBlockedWarning will be issued if the requirements for the transition are not met, but the step will still be taken. Raises a MissingDecisionError if there is no current transition.

def takeAction( self, action: Union[str, Tuple[str, List[bool]]], requires: Optional[exploration.base.Requirement] = None, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto', decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
10205    def takeAction(
10206        self,
10207        action: base.AnyTransition,
10208        requires: Optional[base.Requirement] = None,
10209        consequence: Optional[base.Consequence] = None,
10210        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10211        whichFocus: Optional[base.FocalPointSpecifier] = None,
10212        inCommon: Union[bool, Literal["auto"]] = "auto",
10213        decisionType: base.DecisionType = "active",
10214        challengePolicy: base.ChallengePolicy = "specified"
10215    ) -> base.DecisionID:
10216        """
10217        Adds a new graph to the exploration based on taking the given
10218        action, which must be a self-transition in the graph. If the
10219        action does not already exist in the graph, it will be created.
10220        Either way if requirements and/or a consequence are supplied,
10221        the requirements and consequence of the action will be updated
10222        to match them, and those are the requirements/consequence that
10223        will count.
10224
10225        Returns the decision ID for the decision reached, which normally
10226        is the same action you were just at, but which might be altered
10227        by goto, bounce, and/or follow effects.
10228
10229        Issues a `TransitionBlockedWarning` if the current game state
10230        doesn't satisfy the requirements for the action.
10231
10232        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
10233        used for `deduceTransitionDetailsAtStep`, while `decisionType`
10234        and `challengePolicy` are used for `advanceSituation`.
10235
10236        When an action is being created, `fromDecision` (or
10237        `whichFocus`) must be specified, since the source decision won't
10238        be deducible from the transition name. Note that if a transition
10239        with the given name exists from *any* active decision, it will
10240        be used instead of creating a new action (possibly resulting in
10241        an error if it's not a self-loop transition). Also, you may get
10242        an `AmbiguousTransitionError` if several transitions with that
10243        name exist; in that case use `fromDecision` and/or `whichFocus`
10244        to disambiguate.
10245        """
10246        now = self.getSituation()
10247        graph = now.graph
10248
10249        actionName, outcomes = base.nameAndOutcomes(action)
10250
10251        try:
10252            (
10253                using,
10254                fromID,
10255                destID,
10256                whichFocus
10257            ) = self.deduceTransitionDetailsAtStep(
10258                -1,
10259                actionName,
10260                fromDecision,
10261                whichFocus,
10262                inCommon
10263            )
10264
10265            if destID != fromID:
10266                raise ValueError(
10267                    f"Cannot take action {repr(action)} because it's a"
10268                    f" transition to another decision, not an action"
10269                    f" (use explore, returnTo, and/or retrace instead)."
10270                )
10271
10272        except MissingTransitionError:
10273            using = 'active'
10274            if inCommon is True:
10275                using = 'common'
10276
10277            if fromDecision is not None:
10278                fromID = graph.resolveDecision(fromDecision)
10279            elif whichFocus is not None:
10280                maybeFromID = base.resolvePosition(now, whichFocus)
10281                if maybeFromID is None:
10282                    raise MissingDecisionError(
10283                        f"Focal point {repr(whichFocus)} was specified"
10284                        f" in takeAction but that focal point doesn't"
10285                        f" have a position."
10286                    )
10287                else:
10288                    fromID = maybeFromID
10289            else:
10290                raise AmbiguousTransitionError(
10291                    f"Taking action {repr(action)} is ambiguous because"
10292                    f" the source decision has not been specified via"
10293                    f" either fromDecision or whichFocus, and we"
10294                    f" couldn't find an existing action with that name."
10295                )
10296
10297            # Since the action doesn't exist, add it:
10298            graph.addAction(fromID, actionName, requires, consequence)
10299
10300        # Update the transition requirement/consequence if requested
10301        # (before the action is taken)
10302        if requires is not None:
10303            graph.setTransitionRequirement(fromID, actionName, requires)
10304        if consequence is not None:
10305            graph.setConsequence(fromID, actionName, consequence)
10306
10307        # A move-from-decision action
10308        actionTaken: base.ExplorationAction = (
10309            'take',
10310            using,
10311            fromID,
10312            (actionName, outcomes)
10313        )
10314        if whichFocus is not None:
10315            # A move-from-specific-focal-point action
10316            actionTaken = ('take', whichFocus, (actionName, outcomes))
10317
10318        _, finalDest = self.advanceSituation(
10319            actionTaken,
10320            decisionType,
10321            challengePolicy
10322        )
10323
10324        assert len(finalDest) in (0, 1)
10325        if len(finalDest) == 1:
10326            return next(x for x in finalDest)
10327        else:
10328            return fromID

Adds a new graph to the exploration based on taking the given action, which must be a self-transition in the graph. If the action does not already exist in the graph, it will be created. Either way if requirements and/or a consequence are supplied, the requirements and consequence of the action will be updated to match them, and those are the requirements/consequence that will count.

Returns the decision ID for the decision reached, which normally is the same action you were just at, but which might be altered by goto, bounce, and/or follow effects.

Issues a TransitionBlockedWarning if the current game state doesn't satisfy the requirements for the action.

The fromDecision, whichFocus, and inCommon arguments are used for deduceTransitionDetailsAtStep, while decisionType and challengePolicy are used for advanceSituation.

When an action is being created, fromDecision (or whichFocus) must be specified, since the source decision won't be deducible from the transition name. Note that if a transition with the given name exists from any active decision, it will be used instead of creating a new action (possibly resulting in an error if it's not a self-loop transition). Also, you may get an AmbiguousTransitionError if several transitions with that name exist; in that case use fromDecision and/or whichFocus to disambiguate.

def retrace( self, transition: Union[str, Tuple[str, List[bool]]], fromDecision: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: Union[bool, Literal['auto']] = 'auto', decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
10330    def retrace(
10331        self,
10332        transition: base.AnyTransition,
10333        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10334        whichFocus: Optional[base.FocalPointSpecifier] = None,
10335        inCommon: Union[bool, Literal["auto"]] = "auto",
10336        decisionType: base.DecisionType = "active",
10337        challengePolicy: base.ChallengePolicy = "specified"
10338    ) -> base.DecisionID:
10339        """
10340        Adds a new graph to the exploration based on taking the given
10341        transition, which must already exist and which must not lead to
10342        an unknown region. Returns the ID of the destination decision,
10343        accounting for goto, bounce, and/or follow effects.
10344
10345        Issues a `TransitionBlockedWarning` if the current game state
10346        doesn't satisfy the requirements for the transition.
10347
10348        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
10349        used for `deduceTransitionDetailsAtStep`, while `decisionType`
10350        and `challengePolicy` are used for `advanceSituation`.
10351        """
10352        now = self.getSituation()
10353
10354        transitionName, outcomes = base.nameAndOutcomes(transition)
10355
10356        (
10357            using,
10358            fromID,
10359            destID,
10360            whichFocus
10361        ) = self.deduceTransitionDetailsAtStep(
10362            -1,
10363            transitionName,
10364            fromDecision,
10365            whichFocus,
10366            inCommon
10367        )
10368
10369        visited = self.hasBeenVisited(destID)
10370        confirmed = now.graph.isConfirmed(destID)
10371        if not confirmed:
10372            raise ExplorationStatusError(
10373                f"Cannot retrace transition {transition!r} from"
10374                f" decision {now.graph.identityOf(fromID)} because it"
10375                f" leads to an unconfirmed decision.\nUse"
10376                f" `DiscreteExploration.explore` and provide"
10377                f" destination decision details instead."
10378            )
10379        if not visited:
10380            raise ExplorationStatusError(
10381                f"Cannot retrace transition {transition!r} from"
10382                f" decision {now.graph.identityOf(fromID)} because it"
10383                f" leads to an unvisited decision.\nUse"
10384                f" `DiscreteExploration.explore` and provide"
10385                f" destination decision details instead."
10386            )
10387
10388        # A move-from-decision action
10389        actionTaken: base.ExplorationAction = (
10390            'take',
10391            using,
10392            fromID,
10393            (transitionName, outcomes)
10394        )
10395        if whichFocus is not None:
10396            # A move-from-specific-focal-point action
10397            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10398
10399        _, finalDest = self.advanceSituation(
10400            actionTaken,
10401            decisionType,
10402        challengePolicy
10403    )
10404
10405        assert len(finalDest) == 1
10406        return next(x for x in finalDest)

Adds a new graph to the exploration based on taking the given transition, which must already exist and which must not lead to an unknown region. Returns the ID of the destination decision, accounting for goto, bounce, and/or follow effects.

Issues a TransitionBlockedWarning if the current game state doesn't satisfy the requirements for the transition.

The fromDecision, whichFocus, and inCommon arguments are used for deduceTransitionDetailsAtStep, while decisionType and challengePolicy are used for advanceSituation.

def warp( self, destination: Union[int, exploration.base.DecisionSpecifier, str], consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, domain: Optional[str] = None, zone: Optional[str] = '', whichFocus: Optional[Tuple[Literal['common', 'active'], str, str]] = None, inCommon: bool = False, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> int:
10408    def warp(
10409        self,
10410        destination: base.AnyDecisionSpecifier,
10411        consequence: Optional[base.Consequence] = None,
10412        domain: Optional[base.Domain] = None,
10413        zone: Optional[base.Zone] = base.DefaultZone,
10414        whichFocus: Optional[base.FocalPointSpecifier] = None,
10415        inCommon: Union[bool] = False,
10416        decisionType: base.DecisionType = "active",
10417        challengePolicy: base.ChallengePolicy = "specified"
10418    ) -> base.DecisionID:
10419        """
10420        Adds a new graph to the exploration that's a copy of the current
10421        graph, with the position updated to be at the destination without
10422        actually creating a transition from the old position to the new
10423        one. Returns the ID of the decision warped to (accounting for
10424        any goto or follow effects triggered).
10425
10426        Any provided consequences are applied, but are not associated
10427        with any transition (so any delays and charges are ignored, and
10428        'bounce' effects don't actually cancel the warp). 'goto' or
10429        'follow' effects might change the warp destination; 'follow'
10430        effects take the original destination as their starting point.
10431        Any mechanisms mentioned in extra consequences will be found
10432        based on the destination. Outcomes in supplied challenges should
10433        be pre-specified, or else they will be resolved with the
10434        `challengePolicy`.
10435
10436        `whichFocus` may be specified when the destination domain's
10437        focalization is 'plural' but for 'singular' or 'spreading'
10438        destination domains it is not allowed. `inCommon` determines
10439        whether the common or the active focal context is updated
10440        (default is to update the active context). The `decisionType`
10441        and `challengePolicy` are used for `advanceSituation`.
10442
10443        - If the destination did not already exist, it will be created.
10444            Initially, it will be disconnected from all other decisions.
10445            In this case, the `domain` value can be used to put it in a
10446            non-default domain.
10447        - The position is set to the specified destination, and if a
10448            `consequence` is specified it is applied. Note that
10449            'deactivate' effects are NOT allowed, and 'edit' effects
10450            must establish their own transition target because there is
10451            no transition that the effects are being applied to.
10452        - If the destination had been unexplored, its exploration status
10453            will be set to 'exploring'.
10454        - If a `zone` is specified, the destination will be added to that
10455            zone (even if the destination already existed) and that zone
10456            will be created (as a level-0 zone) if need be. If `zone` is
10457            set to `None`, then no zone will be applied. If `zone` is
10458            left as the default (`base.DefaultZone`) and the
10459            focalization of the destination domain is 'singular' or
10460            'plural' and the destination is newly created and there is
10461            an origin and the origin is in the same domain as the
10462            destination, then the destination will be added to all zones
10463            that the origin was a part of if the destination is newly
10464            created, but otherwise the destination will not be added to
10465            any zones. If the specified zone has to be created and
10466            there's an origin decision, it will be added as a sub-zone
10467            to all parents of zones directly containing the origin, as
10468            long as the origin is in the same domain as the destination.
10469        """
10470        now = self.getSituation()
10471        graph = now.graph
10472
10473        fromID: Optional[base.DecisionID]
10474
10475        new = False
10476        try:
10477            destID = graph.resolveDecision(destination)
10478        except MissingDecisionError:
10479            if isinstance(destination, tuple):
10480                # just the name; ignore zone/domain
10481                destination = destination[-1]
10482
10483            if not isinstance(destination, base.DecisionName):
10484                raise TypeError(
10485                    f"Warp destination {repr(destination)} does not"
10486                    f" exist, and cannot be created as it is not a"
10487                    f" decision name."
10488                )
10489            destID = graph.addDecision(destination, domain)
10490            graph.tagDecision(destID, 'unconfirmed')
10491            self.setExplorationStatus(destID, 'unknown')
10492            new = True
10493
10494        using: base.ContextSpecifier
10495        if inCommon:
10496            targetContext = self.getCommonContext()
10497            using = "common"
10498        else:
10499            targetContext = self.getActiveContext()
10500            using = "active"
10501
10502        destDomain = graph.domainFor(destID)
10503        targetFocalization = base.getDomainFocalization(
10504            targetContext,
10505            destDomain
10506        )
10507        if targetFocalization == 'singular':
10508            targetActive = targetContext['activeDecisions']
10509            if destDomain in targetActive:
10510                fromID = cast(
10511                    base.DecisionID,
10512                    targetContext['activeDecisions'][destDomain]
10513                )
10514            else:
10515                fromID = None
10516        elif targetFocalization == 'plural':
10517            if whichFocus is None:
10518                raise AmbiguousTransitionError(
10519                    f"Warping to {repr(destination)} is ambiguous"
10520                    f" becuase domain {repr(destDomain)} has plural"
10521                    f" focalization, and no whichFocus value was"
10522                    f" specified."
10523                )
10524
10525            fromID = base.resolvePosition(
10526                self.getSituation(),
10527                whichFocus
10528            )
10529        else:
10530            fromID = None
10531
10532        # Handle zones
10533        if zone == base.DefaultZone:
10534            if (
10535                new
10536            and fromID is not None
10537            and graph.domainFor(fromID) == destDomain
10538            ):
10539                for prevZone in graph.zoneParents(fromID):
10540                    graph.addDecisionToZone(destination, prevZone)
10541            # Otherwise don't update zones
10542        elif zone is not None:
10543            # Newness is ignored when a zone is specified
10544            zone = cast(base.Zone, zone)
10545            # Create the zone at level 0 if it didn't already exist
10546            if graph.getZoneInfo(zone) is None:
10547                graph.createZone(zone, 0)
10548                # Add the newly created zone to each 2nd-level parent of
10549                # the previous decision if there is one and it's in the
10550                # same domain
10551                if (
10552                    fromID is not None
10553                and graph.domainFor(fromID) == destDomain
10554                ):
10555                    for prevZone in graph.zoneParents(fromID):
10556                        for prevUpper in graph.zoneParents(prevZone):
10557                            graph.addZoneToZone(zone, prevUpper)
10558            # Finally add the destination to the (maybe new) zone
10559            graph.addDecisionToZone(destID, zone)
10560        # else don't touch zones
10561
10562        # Encode the action taken
10563        actionTaken: base.ExplorationAction
10564        if whichFocus is None:
10565            actionTaken = (
10566                'warp',
10567                using,
10568                destID
10569            )
10570        else:
10571            actionTaken = (
10572                'warp',
10573                whichFocus,
10574                destID
10575            )
10576
10577        # Advance the situation
10578        _, finalDests = self.advanceSituation(
10579            actionTaken,
10580            decisionType,
10581            challengePolicy
10582        )
10583        now = self.getSituation()  # updating just in case
10584
10585        assert len(finalDests) == 1
10586        finalDest = next(x for x in finalDests)
10587
10588        # Apply additional consequences:
10589        if consequence is not None:
10590            altDest = self.applyExtraneousConsequence(
10591                consequence,
10592                where=(destID, None),
10593                # TODO: Mechanism search from both ends?
10594                moveWhich=(
10595                    whichFocus[-1]
10596                    if whichFocus is not None
10597                    else None
10598                )
10599            )
10600            if altDest is not None:
10601                finalDest = altDest
10602            now = self.getSituation()  # updating just in case
10603
10604        return finalDest

Adds a new graph to the exploration that's a copy of the current graph, with the position updated to be at the destination without actually creating a transition from the old position to the new one. Returns the ID of the decision warped to (accounting for any goto or follow effects triggered).

Any provided consequences are applied, but are not associated with any transition (so any delays and charges are ignored, and 'bounce' effects don't actually cancel the warp). 'goto' or 'follow' effects might change the warp destination; 'follow' effects take the original destination as their starting point. Any mechanisms mentioned in extra consequences will be found based on the destination. Outcomes in supplied challenges should be pre-specified, or else they will be resolved with the challengePolicy.

whichFocus may be specified when the destination domain's focalization is 'plural' but for 'singular' or 'spreading' destination domains it is not allowed. inCommon determines whether the common or the active focal context is updated (default is to update the active context). The decisionType and challengePolicy are used for advanceSituation.

  • If the destination did not already exist, it will be created. Initially, it will be disconnected from all other decisions. In this case, the domain value can be used to put it in a non-default domain.
  • The position is set to the specified destination, and if a consequence is specified it is applied. Note that 'deactivate' effects are NOT allowed, and 'edit' effects must establish their own transition target because there is no transition that the effects are being applied to.
  • If the destination had been unexplored, its exploration status will be set to 'exploring'.
  • If a zone is specified, the destination will be added to that zone (even if the destination already existed) and that zone will be created (as a level-0 zone) if need be. If zone is set to None, then no zone will be applied. If zone is left as the default (base.DefaultZone) and the focalization of the destination domain is 'singular' or 'plural' and the destination is newly created and there is an origin and the origin is in the same domain as the destination, then the destination will be added to all zones that the origin was a part of if the destination is newly created, but otherwise the destination will not be added to any zones. If the specified zone has to be created and there's an origin decision, it will be added as a sub-zone to all parents of zones directly containing the origin, as long as the origin is in the same domain as the destination.
def wait( self, consequence: Optional[List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]] = None, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', challengePolicy: Literal['random', 'mostLikely', 'fewestEffects', 'success', 'failure', 'specified'] = 'specified') -> Optional[int]:
10606    def wait(
10607        self,
10608        consequence: Optional[base.Consequence] = None,
10609        decisionType: base.DecisionType = "active",
10610        challengePolicy: base.ChallengePolicy = "specified"
10611    ) -> Optional[base.DecisionID]:
10612        """
10613        Adds a wait step. If a consequence is specified, it is applied,
10614        although it will not have any position/transition information
10615        available during resolution/application.
10616
10617        A decision type other than "active" and/or a challenge policy
10618        other than "specified" can be included (see `advanceSituation`).
10619
10620        The "pending" decision type may not be used, a `ValueError` will
10621        result. This allows None as the action for waiting while
10622        preserving the pending/None type/action combination for
10623        unresolved situations.
10624
10625        If a goto or follow effect in the applied consequence implies a
10626        position update, this will return the new destination ID;
10627        otherwise it will return `None`. Triggering a 'bounce' effect
10628        will be an error, because there is no position information for
10629        the effect.
10630        """
10631        if decisionType == "pending":
10632            raise ValueError(
10633                "The 'pending' decision type may not be used for"
10634                " wait actions."
10635            )
10636        self.advanceSituation(('noAction',), decisionType, challengePolicy)
10637        now = self.getSituation()
10638        if consequence is not None:
10639            if challengePolicy != "specified":
10640                base.resetChallengeOutcomes(consequence)
10641            observed = base.observeChallengeOutcomes(
10642                base.RequirementContext(
10643                    state=now.state,
10644                    graph=now.graph,
10645                    searchFrom=set()
10646                ),
10647                consequence,
10648                location=None,  # No position info
10649                policy=challengePolicy,
10650                knownOutcomes=None  # bake outcomes into the consequence
10651            )
10652            # No location information since we might have multiple
10653            # active decisions and there's no indication of which one
10654            # we're "waiting at."
10655            finalDest = self.applyExtraneousConsequence(observed)
10656            now = self.getSituation()  # updating just in case
10657
10658            return finalDest
10659        else:
10660            return None

Adds a wait step. If a consequence is specified, it is applied, although it will not have any position/transition information available during resolution/application.

A decision type other than "active" and/or a challenge policy other than "specified" can be included (see advanceSituation).

The "pending" decision type may not be used, a ValueError will result. This allows None as the action for waiting while preserving the pending/None type/action combination for unresolved situations.

If a goto or follow effect in the applied consequence implies a position update, this will return the new destination ID; otherwise it will return None. Triggering a 'bounce' effect will be an error, because there is no position information for the effect.

def revert( self, slot: str = 'slot0', aspects: Optional[Set[str]] = None, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active') -> None:
10662    def revert(
10663        self,
10664        slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT,
10665        aspects: Optional[Set[str]] = None,
10666        decisionType: base.DecisionType = "active"
10667    ) -> None:
10668        """
10669        Reverts the game state to a previously-saved game state (saved
10670        via a 'save' effect). The save slot name and set of aspects to
10671        revert are required. By default, all aspects except the graph
10672        are reverted.
10673        """
10674        if aspects is None:
10675            aspects = set()
10676
10677        action: base.ExplorationAction = ("revertTo", slot, aspects)
10678
10679        self.advanceSituation(action, decisionType)

Reverts the game state to a previously-saved game state (saved via a 'save' effect). The save slot name and set of aspects to revert are required. By default, all aspects except the graph are reverted.

def observeAll( self, where: Union[int, exploration.base.DecisionSpecifier, str], *transitions: Union[str, Tuple[str, Union[int, exploration.base.DecisionSpecifier, str]], Tuple[str, Union[int, exploration.base.DecisionSpecifier, str], str]]) -> List[int]:
10681    def observeAll(
10682        self,
10683        where: base.AnyDecisionSpecifier,
10684        *transitions: Union[
10685            base.Transition,
10686            Tuple[base.Transition, base.AnyDecisionSpecifier],
10687            Tuple[
10688                base.Transition,
10689                base.AnyDecisionSpecifier,
10690                base.Transition
10691            ]
10692        ]
10693    ) -> List[base.DecisionID]:
10694        """
10695        Observes one or more new transitions, applying changes to the
10696        current graph. The transitions can be specified in one of three
10697        ways:
10698
10699        1. A transition name. The transition will be created and will
10700            point to a new unexplored node.
10701        2. A pair containing a transition name and a destination
10702            specifier. If the destination does not exist it will be
10703            created as an unexplored node, although in that case the
10704            decision specifier may not be an ID.
10705        3. A triple containing a transition name, a destination
10706            specifier, and a reciprocal name. Works the same as the pair
10707            case but also specifies the name for the reciprocal
10708            transition.
10709
10710        The new transitions are outgoing from specified decision.
10711
10712        Yields the ID of each decision connected to, whether those are
10713        new or existing decisions.
10714        """
10715        now = self.getSituation()
10716        fromID = now.graph.resolveDecision(where)
10717        result = []
10718        for entry in transitions:
10719            if isinstance(entry, base.Transition):
10720                result.append(self.observe(fromID, entry))
10721            else:
10722                result.append(self.observe(fromID, *entry))
10723        return result

Observes one or more new transitions, applying changes to the current graph. The transitions can be specified in one of three ways:

  1. A transition name. The transition will be created and will point to a new unexplored node.
  2. A pair containing a transition name and a destination specifier. If the destination does not exist it will be created as an unexplored node, although in that case the decision specifier may not be an ID.
  3. A triple containing a transition name, a destination specifier, and a reciprocal name. Works the same as the pair case but also specifies the name for the reciprocal transition.

The new transitions are outgoing from specified decision.

Yields the ID of each decision connected to, whether those are new or existing decisions.

def observe( self, where: Union[int, exploration.base.DecisionSpecifier, str], transition: str, destination: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, reciprocal: Optional[str] = None) -> int:
10725    def observe(
10726        self,
10727        where: base.AnyDecisionSpecifier,
10728        transition: base.Transition,
10729        destination: Optional[base.AnyDecisionSpecifier] = None,
10730        reciprocal: Optional[base.Transition] = None
10731    ) -> base.DecisionID:
10732        """
10733        Observes a single new outgoing transition from the specified
10734        decision. If specified the transition connects to a specific
10735        destination and/or has a specific reciprocal. The specified
10736        destination will be created if it doesn't exist, or where no
10737        destination is specified, a new unexplored decision will be
10738        added. The ID of the decision connected to is returned.
10739
10740        Sets the exploration status of the observed destination to
10741        "noticed" if a destination is specified and needs to be created
10742        (but not when no destination is specified).
10743
10744        For example:
10745
10746        >>> e = DiscreteExploration()
10747        >>> e.start('start')
10748        0
10749        >>> e.observe('start', 'up')
10750        1
10751        >>> g = e.getSituation().graph
10752        >>> g.destinationsFrom('start')
10753        {'up': 1}
10754        >>> e.getExplorationStatus(1)  # not given a name: assumed unknown
10755        'unknown'
10756        >>> e.observe('start', 'left', 'A')
10757        2
10758        >>> g.destinationsFrom('start')
10759        {'up': 1, 'left': 2}
10760        >>> g.nameFor(2)
10761        'A'
10762        >>> e.getExplorationStatus(2)  # given a name: noticed
10763        'noticed'
10764        >>> e.observe('start', 'up2', 1)
10765        1
10766        >>> g.destinationsFrom('start')
10767        {'up': 1, 'left': 2, 'up2': 1}
10768        >>> e.getExplorationStatus(1)  # existing decision: status unchanged
10769        'unknown'
10770        >>> e.observe('start', 'right', 'B', 'left')
10771        3
10772        >>> g.destinationsFrom('start')
10773        {'up': 1, 'left': 2, 'up2': 1, 'right': 3}
10774        >>> g.nameFor(3)
10775        'B'
10776        >>> e.getExplorationStatus(3)  # new + name -> noticed
10777        'noticed'
10778        >>> e.observe('start', 'right')  # repeat transition name
10779        Traceback (most recent call last):
10780        ...
10781        exploration.core.TransitionCollisionError...
10782        >>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
10783        Traceback (most recent call last):
10784        ...
10785        exploration.core.TransitionCollisionError...
10786        >>> g = e.getSituation().graph
10787        >>> g.createZone('Z', 0)
10788        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
10789 annotations=[])
10790        >>> g.addDecisionToZone('start', 'Z')
10791        >>> e.observe('start', 'down', 'C', 'up')
10792        4
10793        >>> g.destinationsFrom('start')
10794        {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
10795        >>> g.identityOf('C')
10796        '4 (C)'
10797        >>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
10798        set()
10799        >>> e.observe(
10800        ...     'C',
10801        ...     'right',
10802        ...     base.DecisionSpecifier('main', 'Z2', 'D'),
10803        ... )  # creates zone
10804        5
10805        >>> g.destinationsFrom('C')
10806        {'up': 0, 'right': 5}
10807        >>> g.destinationsFrom('D')  # default reciprocal name
10808        {'return': 4}
10809        >>> g.identityOf('D')
10810        '5 (Z2::D)'
10811        >>> g.zoneParents(5)
10812        {'Z2'}
10813        """
10814        now = self.getSituation()
10815        fromID = now.graph.resolveDecision(where)
10816
10817        kwargs: Dict[
10818            str,
10819            Union[base.Transition, base.DecisionName, None]
10820        ] = {}
10821        if reciprocal is not None:
10822            kwargs['reciprocal'] = reciprocal
10823
10824        if destination is not None:
10825            try:
10826                destID = now.graph.resolveDecision(destination)
10827                now.graph.addTransition(
10828                    fromID,
10829                    transition,
10830                    destID,
10831                    reciprocal
10832                )
10833                return destID
10834            except MissingDecisionError:
10835                if isinstance(destination, base.DecisionSpecifier):
10836                    kwargs['toDomain'] = destination.domain
10837                    kwargs['placeInZone'] = destination.zone
10838                    kwargs['destinationName'] = destination.name
10839                elif isinstance(destination, base.DecisionName):
10840                    kwargs['destinationName'] = destination
10841                else:
10842                    assert isinstance(destination, base.DecisionID)
10843                    # We got to except by failing to resolve, so it's an
10844                    # invalid ID
10845                    raise
10846
10847        result = now.graph.addUnexploredEdge(
10848            fromID,
10849            transition,
10850            **kwargs  # type: ignore [arg-type]
10851        )
10852        if 'destinationName' in kwargs:
10853            self.setExplorationStatus(result, 'noticed', upgradeOnly=True)
10854        return result

Observes a single new outgoing transition from the specified decision. If specified the transition connects to a specific destination and/or has a specific reciprocal. The specified destination will be created if it doesn't exist, or where no destination is specified, a new unexplored decision will be added. The ID of the decision connected to is returned.

Sets the exploration status of the observed destination to "noticed" if a destination is specified and needs to be created (but not when no destination is specified).

For example:

>>> e = DiscreteExploration()
>>> e.start('start')
0
>>> e.observe('start', 'up')
1
>>> g = e.getSituation().graph
>>> g.destinationsFrom('start')
{'up': 1}
>>> e.getExplorationStatus(1)  # not given a name: assumed unknown
'unknown'
>>> e.observe('start', 'left', 'A')
2
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2}
>>> g.nameFor(2)
'A'
>>> e.getExplorationStatus(2)  # given a name: noticed
'noticed'
>>> e.observe('start', 'up2', 1)
1
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1}
>>> e.getExplorationStatus(1)  # existing decision: status unchanged
'unknown'
>>> e.observe('start', 'right', 'B', 'left')
3
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1, 'right': 3}
>>> g.nameFor(3)
'B'
>>> e.getExplorationStatus(3)  # new + name -> noticed
'noticed'
>>> e.observe('start', 'right')  # repeat transition name
Traceback (most recent call last):
...
TransitionCollisionError...
>>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
Traceback (most recent call last):
...
TransitionCollisionError...
>>> g = e.getSituation().graph
>>> g.createZone('Z', 0)
ZoneInfo(level=0, parents=set(), contents=set(), tags={}, annotations=[])
>>> g.addDecisionToZone('start', 'Z')
>>> e.observe('start', 'down', 'C', 'up')
4
>>> g.destinationsFrom('start')
{'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
>>> g.identityOf('C')
'4 (C)'
>>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
set()
>>> e.observe(
...     'C',
...     'right',
...     base.DecisionSpecifier('main', 'Z2', 'D'),
... )  # creates zone
5
>>> g.destinationsFrom('C')
{'up': 0, 'right': 5}
>>> g.destinationsFrom('D')  # default reciprocal name
{'return': 4}
>>> g.identityOf('D')
'5 (Z2::D)'
>>> g.zoneParents(5)
{'Z2'}
def observeMechanisms( self, where: Union[int, exploration.base.DecisionSpecifier, str, NoneType], *mechanisms: Union[str, Tuple[str, str]]) -> List[int]:
10856    def observeMechanisms(
10857        self,
10858        where: Optional[base.AnyDecisionSpecifier],
10859        *mechanisms: Union[
10860            base.MechanismName,
10861            Tuple[base.MechanismName, base.MechanismState]
10862        ]
10863    ) -> List[base.MechanismID]:
10864        """
10865        Adds one or more mechanisms to the exploration's current graph,
10866        located at the specified decision. Global mechanisms can be
10867        added by using `None` for the location. Mechanisms are named, or
10868        a (name, state) tuple can be used to set them into a specific
10869        state. Mechanisms not set to a state will be in the
10870        `base.DEFAULT_MECHANISM_STATE`.
10871        """
10872        now = self.getSituation()
10873        result = []
10874        for mSpec in mechanisms:
10875            setState = None
10876            if isinstance(mSpec, base.MechanismName):
10877                result.append(now.graph.addMechanism(mSpec, where))
10878            elif (
10879                isinstance(mSpec, tuple)
10880            and len(mSpec) == 2
10881            and isinstance(mSpec[0], base.MechanismName)
10882            and isinstance(mSpec[1], base.MechanismState)
10883            ):
10884                result.append(now.graph.addMechanism(mSpec[0], where))
10885                setState = mSpec[1]
10886            else:
10887                raise TypeError(
10888                    f"Invalid mechanism: {repr(mSpec)} (must be a"
10889                    f" mechanism name or a (name, state) tuple."
10890                )
10891
10892            if setState:
10893                self.setMechanismStateNow(result[-1], setState)
10894
10895        return result

Adds one or more mechanisms to the exploration's current graph, located at the specified decision. Global mechanisms can be added by using None for the location. Mechanisms are named, or a (name, state) tuple can be used to set them into a specific state. Mechanisms not set to a state will be in the base.DEFAULT_MECHANISM_STATE.

def reZone( self, zone: str, where: Union[int, exploration.base.DecisionSpecifier, str], replace: Union[str, int] = 0) -> None:
10897    def reZone(
10898        self,
10899        zone: base.Zone,
10900        where: base.AnyDecisionSpecifier,
10901        replace: Union[base.Zone, int] = 0
10902    ) -> None:
10903        """
10904        Alters the current graph without adding a new exploration step.
10905
10906        Calls `DecisionGraph.replaceZonesInHierarchy` targeting the
10907        specified decision. Note that per the logic of that method, ALL
10908        zones at the specified hierarchy level are replaced, even if a
10909        specific zone to replace is specified here.
10910
10911        TODO: not that?
10912
10913        The level value is either specified via `replace` (default 0) or
10914        deduced from the zone provided as the `replace` value using
10915        `DecisionGraph.zoneHierarchyLevel`.
10916        """
10917        now = self.getSituation()
10918
10919        if isinstance(replace, int):
10920            level = replace
10921        else:
10922            level = now.graph.zoneHierarchyLevel(replace)
10923
10924        now.graph.replaceZonesInHierarchy(where, zone, level)

Alters the current graph without adding a new exploration step.

Calls DecisionGraph.replaceZonesInHierarchy targeting the specified decision. Note that per the logic of that method, ALL zones at the specified hierarchy level are replaced, even if a specific zone to replace is specified here.

TODO: not that?

The level value is either specified via replace (default 0) or deduced from the zone provided as the replace value using DecisionGraph.zoneHierarchyLevel.

10926    def runCommand(
10927        self,
10928        command: commands.Command,
10929        scope: Optional[commands.Scope] = None,
10930        line: int = -1
10931    ) -> commands.CommandResult:
10932        """
10933        Runs a single `Command` applying effects to the exploration, its
10934        current graph, and the provided execution context, and returning
10935        a command result, which contains the modified scope plus
10936        optional skip and label values (see `CommandResult`). This
10937        function also directly modifies the scope you give it. Variable
10938        references in the command are resolved via entries in the
10939        provided scope. If no scope is given, an empty one is created.
10940
10941        A line number may be supplied for use in error messages; if left
10942        out line -1 will be used.
10943
10944        Raises an error if the command is invalid.
10945
10946        For commands that establish a value as the 'current value', that
10947        value will be stored in the '_' variable. When this happens, the
10948        old contents of '_' are stored in '__' first, and the old
10949        contents of '__' are discarded. Note that non-automatic
10950        assignment to '_' does not move the old value to '__'.
10951        """
10952        try:
10953            if scope is None:
10954                scope = {}
10955
10956            skip: Union[int, str, None] = None
10957            label: Optional[str] = None
10958
10959            if command.command == 'val':
10960                command = cast(commands.LiteralValue, command)
10961                result = commands.resolveValue(command.value, scope)
10962                commands.pushCurrentValue(scope, result)
10963
10964            elif command.command == 'empty':
10965                command = cast(commands.EstablishCollection, command)
10966                collection = commands.resolveVarName(command.collection, scope)
10967                commands.pushCurrentValue(
10968                    scope,
10969                    {
10970                        'list': [],
10971                        'tuple': (),
10972                        'set': set(),
10973                        'dict': {},
10974                    }[collection]
10975                )
10976
10977            elif command.command == 'append':
10978                command = cast(commands.AppendValue, command)
10979                target = scope['_']
10980                addIt = commands.resolveValue(command.value, scope)
10981                if isinstance(target, list):
10982                    target.append(addIt)
10983                elif isinstance(target, tuple):
10984                    scope['_'] = target + (addIt,)
10985                elif isinstance(target, set):
10986                    target.add(addIt)
10987                elif isinstance(target, dict):
10988                    raise TypeError(
10989                        "'append' command cannot be used with a"
10990                        " dictionary. Use 'set' instead."
10991                    )
10992                else:
10993                    raise TypeError(
10994                        f"Invalid current value for 'append' command."
10995                        f" The current value must be a list, tuple, or"
10996                        f" set, but it was a '{type(target).__name__}'."
10997                    )
10998
10999            elif command.command == 'set':
11000                command = cast(commands.SetValue, command)
11001                target = scope['_']
11002                where = commands.resolveValue(command.location, scope)
11003                what = commands.resolveValue(command.value, scope)
11004                if isinstance(target, list):
11005                    if not isinstance(where, int):
11006                        raise TypeError(
11007                            f"Cannot set item in list: index {where!r}"
11008                            f" is not an integer."
11009                        )
11010                    target[where] = what
11011                elif isinstance(target, tuple):
11012                    if not isinstance(where, int):
11013                        raise TypeError(
11014                            f"Cannot set item in tuple: index {where!r}"
11015                            f" is not an integer."
11016                        )
11017                    if not (
11018                        0 <= where < len(target)
11019                    or -1 >= where >= -len(target)
11020                    ):
11021                        raise IndexError(
11022                            f"Cannot set item in tuple at index"
11023                            f" {where}: Tuple has length {len(target)}."
11024                        )
11025                    scope['_'] = target[:where] + (what,) + target[where + 1:]
11026                elif isinstance(target, set):
11027                    if what:
11028                        target.add(where)
11029                    else:
11030                        try:
11031                            target.remove(where)
11032                        except KeyError:
11033                            pass
11034                elif isinstance(target, dict):
11035                    target[where] = what
11036
11037            elif command.command == 'pop':
11038                command = cast(commands.PopValue, command)
11039                target = scope['_']
11040                if isinstance(target, list):
11041                    result = target.pop()
11042                    commands.pushCurrentValue(scope, result)
11043                elif isinstance(target, tuple):
11044                    result = target[-1]
11045                    updated = target[:-1]
11046                    scope['__'] = updated
11047                    scope['_'] = result
11048                else:
11049                    raise TypeError(
11050                        f"Cannot 'pop' from a {type(target).__name__}"
11051                        f" (current value must be a list or tuple)."
11052                    )
11053
11054            elif command.command == 'get':
11055                command = cast(commands.GetValue, command)
11056                target = scope['_']
11057                where = commands.resolveValue(command.location, scope)
11058                if isinstance(target, list):
11059                    if not isinstance(where, int):
11060                        raise TypeError(
11061                            f"Cannot get item from list: index"
11062                            f" {where!r} is not an integer."
11063                        )
11064                elif isinstance(target, tuple):
11065                    if not isinstance(where, int):
11066                        raise TypeError(
11067                            f"Cannot get item from tuple: index"
11068                            f" {where!r} is not an integer."
11069                        )
11070                elif isinstance(target, set):
11071                    result = where in target
11072                    commands.pushCurrentValue(scope, result)
11073                elif isinstance(target, dict):
11074                    result = target[where]
11075                    commands.pushCurrentValue(scope, result)
11076                else:
11077                    result = getattr(target, where)
11078                    commands.pushCurrentValue(scope, result)
11079
11080            elif command.command == 'remove':
11081                command = cast(commands.RemoveValue, command)
11082                target = scope['_']
11083                where = commands.resolveValue(command.location, scope)
11084                if isinstance(target, (list, tuple)):
11085                    # this cast is not correct but suppresses warnings
11086                    # given insufficient narrowing by MyPy
11087                    target = cast(Tuple[Any, ...], target)
11088                    if not isinstance(where, int):
11089                        raise TypeError(
11090                            f"Cannot remove item from list or tuple:"
11091                            f" index {where!r} is not an integer."
11092                        )
11093                    scope['_'] = target[:where] + target[where + 1:]
11094                elif isinstance(target, set):
11095                    target.remove(where)
11096                elif isinstance(target, dict):
11097                    del target[where]
11098                else:
11099                    raise TypeError(
11100                        f"Cannot use 'remove' on a/an"
11101                        f" {type(target).__name__}."
11102                    )
11103
11104            elif command.command == 'op':
11105                command = cast(commands.ApplyOperator, command)
11106                left = commands.resolveValue(command.left, scope)
11107                right = commands.resolveValue(command.right, scope)
11108                op = command.op
11109                if op == '+':
11110                    result = left + right
11111                elif op == '-':
11112                    result = left - right
11113                elif op == '*':
11114                    result = left * right
11115                elif op == '/':
11116                    result = left / right
11117                elif op == '//':
11118                    result = left // right
11119                elif op == '**':
11120                    result = left ** right
11121                elif op == '%':
11122                    result = left % right
11123                elif op == '^':
11124                    result = left ^ right
11125                elif op == '|':
11126                    result = left | right
11127                elif op == '&':
11128                    result = left & right
11129                elif op == 'and':
11130                    result = left and right
11131                elif op == 'or':
11132                    result = left or right
11133                elif op == '<':
11134                    result = left < right
11135                elif op == '>':
11136                    result = left > right
11137                elif op == '<=':
11138                    result = left <= right
11139                elif op == '>=':
11140                    result = left >= right
11141                elif op == '==':
11142                    result = left == right
11143                elif op == 'is':
11144                    result = left is right
11145                else:
11146                    raise RuntimeError("Invalid operator '{op}'.")
11147
11148                commands.pushCurrentValue(scope, result)
11149
11150            elif command.command == 'unary':
11151                command = cast(commands.ApplyUnary, command)
11152                value = commands.resolveValue(command.value, scope)
11153                op = command.op
11154                if op == '-':
11155                    result = -value
11156                elif op == '~':
11157                    result = ~value
11158                elif op == 'not':
11159                    result = not value
11160
11161                commands.pushCurrentValue(scope, result)
11162
11163            elif command.command == 'assign':
11164                command = cast(commands.VariableAssignment, command)
11165                varname = commands.resolveVarName(command.varname, scope)
11166                value = commands.resolveValue(command.value, scope)
11167                scope[varname] = value
11168
11169            elif command.command == 'delete':
11170                command = cast(commands.VariableDeletion, command)
11171                varname = commands.resolveVarName(command.varname, scope)
11172                del scope[varname]
11173
11174            elif command.command == 'load':
11175                command = cast(commands.LoadVariable, command)
11176                varname = commands.resolveVarName(command.varname, scope)
11177                commands.pushCurrentValue(scope, scope[varname])
11178
11179            elif command.command == 'call':
11180                command = cast(commands.FunctionCall, command)
11181                function = command.function
11182                if function.startswith('$'):
11183                    function = commands.resolveValue(function, scope)
11184
11185                toCall: Callable
11186                args: Tuple[str, ...]
11187                kwargs: Dict[str, Any]
11188
11189                if command.target == 'builtin':
11190                    toCall = commands.COMMAND_BUILTINS[function]
11191                    args = (scope['_'],)
11192                    kwargs = {}
11193                    if toCall == round:
11194                        if 'ndigits' in scope:
11195                            kwargs['ndigits'] = scope['ndigits']
11196                    elif toCall == range and args[0] is None:
11197                        start = scope.get('start', 0)
11198                        stop = scope['stop']
11199                        step = scope.get('step', 1)
11200                        args = (start, stop, step)
11201
11202                else:
11203                    if command.target == 'stored':
11204                        toCall = function
11205                    elif command.target == 'graph':
11206                        toCall = getattr(self.getSituation().graph, function)
11207                    elif command.target == 'exploration':
11208                        toCall = getattr(self, function)
11209                    else:
11210                        raise TypeError(
11211                            f"Invalid call target '{command.target}'"
11212                            f" (must be one of 'builtin', 'stored',"
11213                            f" 'graph', or 'exploration'."
11214                        )
11215
11216                    # Fill in arguments via kwargs defined in scope
11217                    args = ()
11218                    kwargs = {}
11219                    signature = inspect.signature(toCall)
11220                    # TODO: Maybe try some type-checking here?
11221                    for argName, param in signature.parameters.items():
11222                        if param.kind == inspect.Parameter.VAR_POSITIONAL:
11223                            if argName in scope:
11224                                args = args + tuple(scope[argName])
11225                            # Else leave args as-is
11226                        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
11227                            # These must have a default
11228                            if argName in scope:
11229                                kwargs[argName] = scope[argName]
11230                        elif param.kind == inspect.Parameter.VAR_KEYWORD:
11231                            # treat as a dictionary
11232                            if argName in scope:
11233                                argsToUse = scope[argName]
11234                                if not isinstance(argsToUse, dict):
11235                                    raise TypeError(
11236                                        f"Variable '{argName}' must"
11237                                        f" hold a dictionary when"
11238                                        f" calling function"
11239                                        f" '{toCall.__name__} which"
11240                                        f" uses that argument as a"
11241                                        f" keyword catchall."
11242                                    )
11243                                kwargs.update(scope[argName])
11244                        else:  # a normal parameter
11245                            if argName in scope:
11246                                args = args + (scope[argName],)
11247                            elif param.default == inspect.Parameter.empty:
11248                                raise TypeError(
11249                                    f"No variable named '{argName}' has"
11250                                    f" been defined to supply the"
11251                                    f" required parameter with that"
11252                                    f" name for function"
11253                                    f" '{toCall.__name__}'."
11254                                )
11255
11256                result = toCall(*args, **kwargs)
11257                commands.pushCurrentValue(scope, result)
11258
11259            elif command.command == 'skip':
11260                command = cast(commands.SkipCommands, command)
11261                doIt = commands.resolveValue(command.condition, scope)
11262                if doIt:
11263                    skip = commands.resolveValue(command.amount, scope)
11264                    if not isinstance(skip, (int, str)):
11265                        raise TypeError(
11266                            f"Skip amount must be an integer or a label"
11267                            f" name (got {skip!r})."
11268                        )
11269
11270            elif command.command == 'label':
11271                command = cast(commands.Label, command)
11272                label = commands.resolveValue(command.name, scope)
11273                if not isinstance(label, str):
11274                    raise TypeError(
11275                        f"Label name must be a string (got {label!r})."
11276                    )
11277
11278            else:
11279                raise ValueError(
11280                    f"Invalid command type: {command.command!r}"
11281                )
11282        except ValueError as e:
11283            raise commands.CommandValueError(command, line, e)
11284        except TypeError as e:
11285            raise commands.CommandTypeError(command, line, e)
11286        except IndexError as e:
11287            raise commands.CommandIndexError(command, line, e)
11288        except KeyError as e:
11289            raise commands.CommandKeyError(command, line, e)
11290        except Exception as e:
11291            raise commands.CommandOtherError(command, line, e)
11292
11293        return (scope, skip, label)

Runs a single Command applying effects to the exploration, its current graph, and the provided execution context, and returning a command result, which contains the modified scope plus optional skip and label values (see CommandResult). This function also directly modifies the scope you give it. Variable references in the command are resolved via entries in the provided scope. If no scope is given, an empty one is created.

A line number may be supplied for use in error messages; if left out line -1 will be used.

Raises an error if the command is invalid.

For commands that establish a value as the 'current value', that value will be stored in the '_' variable. When this happens, the old contents of '_' are stored in '__' first, and the old contents of '__' are discarded. Note that non-automatic assignment to '_' does not move the old value to '__'.

11295    def runCommandBlock(
11296        self,
11297        block: List[commands.Command],
11298        scope: Optional[commands.Scope] = None
11299    ) -> commands.Scope:
11300        """
11301        Runs a list of commands, using the given scope (or creating a new
11302        empty scope if none was provided). Returns the scope after
11303        running all of the commands, which may also edit the exploration
11304        and/or the current graph of course.
11305
11306        Note that if a skip command would skip past the end of the
11307        block, execution will end. If a skip command would skip before
11308        the beginning of the block, execution will start from the first
11309        command.
11310
11311        Example:
11312
11313        >>> e = DiscreteExploration()
11314        >>> scope = e.runCommandBlock([
11315        ...    commands.command('assign', 'decision', "'START'"),
11316        ...    commands.command('call', 'exploration', 'start'),
11317        ...    commands.command('assign', 'where', '$decision'),
11318        ...    commands.command('assign', 'transition', "'left'"),
11319        ...    commands.command('call', 'exploration', 'observe'),
11320        ...    commands.command('assign', 'transition', "'right'"),
11321        ...    commands.command('call', 'exploration', 'observe'),
11322        ...    commands.command('call', 'graph', 'destinationsFrom'),
11323        ...    commands.command('call', 'builtin', 'print'),
11324        ...    commands.command('assign', 'transition', "'right'"),
11325        ...    commands.command('assign', 'destination', "'EastRoom'"),
11326        ...    commands.command('call', 'exploration', 'explore'),
11327        ... ])
11328        {'left': 1, 'right': 2}
11329        >>> scope['decision']
11330        'START'
11331        >>> scope['where']
11332        'START'
11333        >>> scope['_']  # result of 'explore' call is dest ID
11334        2
11335        >>> scope['transition']
11336        'right'
11337        >>> scope['destination']
11338        'EastRoom'
11339        >>> g = e.getSituation().graph
11340        >>> len(e)
11341        3
11342        >>> len(g)
11343        3
11344        >>> g.namesListing(g)
11345        '  0 (START)\\n  1 (_u.0)\\n  2 (EastRoom)\\n'
11346        """
11347        if scope is None:
11348            scope = {}
11349
11350        labelPositions: Dict[str, List[int]] = {}
11351
11352        # Keep going until we've exhausted the commands list
11353        index = 0
11354        while index < len(block):
11355
11356            # Execute the next command
11357            scope, skip, label = self.runCommand(
11358                block[index],
11359                scope,
11360                index + 1
11361            )
11362
11363            # Increment our index, or apply a skip
11364            if skip is None:
11365                index = index + 1
11366
11367            elif isinstance(skip, int):  # Integer skip value
11368                if skip < 0:
11369                    index += skip
11370                    if index < 0:  # can't skip before the start
11371                        index = 0
11372                else:
11373                    index += skip + 1  # may end loop if we skip too far
11374
11375            else:  # must be a label name
11376                if skip in labelPositions:  # an established label
11377                    # We jump to the last previous index, or if there
11378                    # are none, to the first future index.
11379                    prevIndices = [
11380                        x
11381                        for x in labelPositions[skip]
11382                        if x < index
11383                    ]
11384                    futureIndices = [
11385                        x
11386                        for x in labelPositions[skip]
11387                        if x >= index
11388                    ]
11389                    if len(prevIndices) > 0:
11390                        index = max(prevIndices)
11391                    else:
11392                        index = min(futureIndices)
11393                else:  # must be a forward-reference
11394                    for future in range(index + 1, len(block)):
11395                        inspect = block[future]
11396                        if inspect.command == 'label':
11397                            inspect = cast(commands.Label, inspect)
11398                            if inspect.name == skip:
11399                                index = future
11400                                break
11401                    else:
11402                        raise KeyError(
11403                            f"Skip command indicated a jump to label"
11404                            f" {skip!r} but that label had not already"
11405                            f" been defined and there is no future"
11406                            f" label with that name either (future"
11407                            f" labels based on variables cannot be"
11408                            f" skipped to from above as their names"
11409                            f" are not known yet)."
11410                        )
11411
11412            # If there's a label, record it
11413            if label is not None:
11414                labelPositions.setdefault(label, []).append(index)
11415
11416            # And now the while loop continues, or ends if we're at the
11417            # end of the commands list.
11418
11419        # Return the scope object.
11420        return scope

Runs a list of commands, using the given scope (or creating a new empty scope if none was provided). Returns the scope after running all of the commands, which may also edit the exploration and/or the current graph of course.

Note that if a skip command would skip past the end of the block, execution will end. If a skip command would skip before the beginning of the block, execution will start from the first command.

Example:

>>> e = DiscreteExploration()
>>> scope = e.runCommandBlock([
...    commands.command('assign', 'decision', "'START'"),
...    commands.command('call', 'exploration', 'start'),
...    commands.command('assign', 'where', '$decision'),
...    commands.command('assign', 'transition', "'left'"),
...    commands.command('call', 'exploration', 'observe'),
...    commands.command('assign', 'transition', "'right'"),
...    commands.command('call', 'exploration', 'observe'),
...    commands.command('call', 'graph', 'destinationsFrom'),
...    commands.command('call', 'builtin', 'print'),
...    commands.command('assign', 'transition', "'right'"),
...    commands.command('assign', 'destination', "'EastRoom'"),
...    commands.command('call', 'exploration', 'explore'),
... ])
{'left': 1, 'right': 2}
>>> scope['decision']
'START'
>>> scope['where']
'START'
>>> scope['_']  # result of 'explore' call is dest ID
2
>>> scope['transition']
'right'
>>> scope['destination']
'EastRoom'
>>> g = e.getSituation().graph
>>> len(e)
3
>>> len(g)
3
>>> g.namesListing(g)
'  0 (START)\n  1 (_u.0)\n  2 (EastRoom)\n'
@staticmethod
def example() -> DiscreteExploration:
11422    @staticmethod
11423    def example() -> 'DiscreteExploration':
11424        """
11425        Returns a little example exploration. Has a few decisions
11426        including one that's unexplored, and uses a few steps to explore
11427        them.
11428
11429        >>> e = DiscreteExploration.example()
11430        >>> len(e)
11431        7
11432        >>> def pg(n):
11433        ...     print(e[n].graph.namesListing(e[n].graph))
11434        >>> pg(0)
11435          0 (House)
11436        <BLANKLINE>
11437        >>> pg(1)
11438          0 (House)
11439          1 (_u.0)
11440          2 (_u.1)
11441          3 (_u.2)
11442        <BLANKLINE>
11443        >>> pg(2)
11444          0 (House)
11445          1 (_u.0)
11446          2 (_u.1)
11447          3 (Yard)
11448          4 (_u.3)
11449          5 (_u.4)
11450        <BLANKLINE>
11451        >>> pg(3)
11452          0 (House)
11453          1 (_u.0)
11454          2 (_u.1)
11455          3 (Yard)
11456          4 (_u.3)
11457          5 (_u.4)
11458        <BLANKLINE>
11459        >>> pg(4)
11460          0 (House)
11461          1 (_u.0)
11462          2 (Cellar)
11463          3 (Yard)
11464          5 (_u.4)
11465        <BLANKLINE>
11466        >>> pg(5)
11467          0 (House)
11468          1 (_u.0)
11469          2 (Cellar)
11470          3 (Yard)
11471          5 (_u.4)
11472        <BLANKLINE>
11473        >>> pg(6)
11474          0 (House)
11475          1 (_u.0)
11476          2 (Cellar)
11477          3 (Yard)
11478          5 (Lane)
11479        <BLANKLINE>
11480        """
11481        result = DiscreteExploration()
11482        result.start("House")
11483        result.observeAll("House", "ladder", "stairsDown", "frontDoor")
11484        result.explore("frontDoor", "Yard", "frontDoor")
11485        result.observe("Yard", "cellarDoors")
11486        result.observe("Yard", "frontGate")
11487        result.retrace("frontDoor")
11488        result.explore("stairsDown", "Cellar", "stairsUp")
11489        result.observe("Cellar", "stairsOut")
11490        result.returnTo("stairsOut", "Yard", "cellarDoors")
11491        result.explore("frontGate", "Lane", "redGate")
11492        return result

Returns a little example exploration. Has a few decisions including one that's unexplored, and uses a few steps to explore them.

>>> e = DiscreteExploration.example()
>>> len(e)
7
>>> def pg(n):
...     print(e[n].graph.namesListing(e[n].graph))
>>> pg(0)
  0 (House)
<BLANKLINE>
>>> pg(1)
  0 (House)
  1 (_u.0)
  2 (_u.1)
  3 (_u.2)
<BLANKLINE>
>>> pg(2)
  0 (House)
  1 (_u.0)
  2 (_u.1)
  3 (Yard)
  4 (_u.3)
  5 (_u.4)
<BLANKLINE>
>>> pg(3)
  0 (House)
  1 (_u.0)
  2 (_u.1)
  3 (Yard)
  4 (_u.3)
  5 (_u.4)
<BLANKLINE>
>>> pg(4)
  0 (House)
  1 (_u.0)
  2 (Cellar)
  3 (Yard)
  5 (_u.4)
<BLANKLINE>
>>> pg(5)
  0 (House)
  1 (_u.0)
  2 (Cellar)
  3 (Yard)
  5 (_u.4)
<BLANKLINE>
>>> pg(6)
  0 (House)
  1 (_u.0)
  2 (Cellar)
  3 (Yard)
  5 (Lane)
<BLANKLINE>