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 identityOf(
  937        self,
  938        decision: Optional[base.AnyDecisionSpecifier],
  939        includeZones: bool = True,
  940        alwaysDomain: Optional[bool] = None
  941    ) -> str:
  942        """
  943        Returns a string containing the given decision ID and the name
  944        for that decision in parentheses afterwards. If the value
  945        provided 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"{dID} ({dSpec}{zSpec}{self.nameFor(dID)})"
  983
  984    def namesListing(
  985        self,
  986        decisions: Collection[base.DecisionID],
  987        includeZones: bool = True,
  988        indent: int = 2
  989    ) -> str:
  990        """
  991        Returns a multi-line string containing an indented listing of
  992        the provided decision IDs with their names in parentheses after
  993        each. Useful for debugging & error messages.
  994
  995        Includes level-0 zones where applicable, with a zone separator
  996        before the decision, unless `includeZones` is set to False. Where
  997        there are multiple level-0 zones, they're listed together in
  998        brackets.
  999
 1000        Uses the string '(none)' when there are no decisions are in the
 1001        list.
 1002
 1003        Set `indent` to something other than 2 to control how much
 1004        indentation is added.
 1005
 1006        For example:
 1007
 1008        >>> g = DecisionGraph()
 1009        >>> g.addDecision('A')
 1010        0
 1011        >>> g.addDecision('B')
 1012        1
 1013        >>> g.addDecision('C')
 1014        2
 1015        >>> g.namesListing(['A', 'C', 'B'])
 1016        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
 1017        >>> g.namesListing([])
 1018        '  (none)\\n'
 1019        >>> g.createZone('zone', 0)
 1020        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1021 annotations=[])
 1022        >>> g.createZone('zone2', 0)
 1023        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1024 annotations=[])
 1025        >>> g.createZone('zoneUp', 1)
 1026        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 1027 annotations=[])
 1028        >>> g.addDecisionToZone(0, 'zone')
 1029        >>> g.addDecisionToZone(1, 'zone')
 1030        >>> g.addDecisionToZone(1, 'zone2')
 1031        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
 1032        >>> g.namesListing(['A', 'C', 'B'])
 1033        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
 1034        """
 1035        ind = ' ' * indent
 1036        if len(decisions) == 0:
 1037            return ind + '(none)\n'
 1038        else:
 1039            result = ''
 1040            for dID in decisions:
 1041                result += ind + self.identityOf(dID, includeZones) + '\n'
 1042            return result
 1043
 1044    def destinationsListing(
 1045        self,
 1046        destinations: Dict[base.Transition, base.DecisionID],
 1047        includeZones: bool = True,
 1048        indent: int = 2
 1049    ) -> str:
 1050        """
 1051        Returns a multi-line string containing an indented listing of
 1052        the provided transitions along with their destinations and the
 1053        names of those destinations in parentheses. Useful for debugging
 1054        & error messages. (Use e.g., `destinationsFrom` to get a
 1055        transitions -> destinations dictionary in the required format.)
 1056
 1057        Uses the string '(no transitions)' when there are no transitions
 1058        in the dictionary.
 1059
 1060        Set `indent` to something other than 2 to control how much
 1061        indentation is added.
 1062
 1063        For example:
 1064
 1065        >>> g = DecisionGraph()
 1066        >>> g.addDecision('A')
 1067        0
 1068        >>> g.addDecision('B')
 1069        1
 1070        >>> g.addDecision('C')
 1071        2
 1072        >>> g.addTransition('A', 'north', 'B', 'south')
 1073        >>> g.addTransition('B', 'east', 'C', 'west')
 1074        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
 1075        >>> g.destinationsListing(g.destinationsFrom('A'))
 1076        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
 1077        >>> g.destinationsListing(g.destinationsFrom('B'))
 1078        '  south to 0 (A)\\n  east to 2 (C)\\n'
 1079        >>> g.destinationsListing({})
 1080        '  (none)\\n'
 1081        >>> g.createZone('zone', 0)
 1082        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1083 annotations=[])
 1084        >>> g.addDecisionToZone(0, 'zone')
 1085        >>> g.destinationsListing(g.destinationsFrom('B'))
 1086        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
 1087        """
 1088        ind = ' ' * indent
 1089        if len(destinations) == 0:
 1090            return ind + '(none)\n'
 1091        else:
 1092            result = ''
 1093            for transition, dID in destinations.items():
 1094                line = f"{transition} to {self.identityOf(dID, includeZones)}"
 1095                result += ind + line + '\n'
 1096            return result
 1097
 1098    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
 1099        """
 1100        Returns the domain that a decision belongs to.
 1101        """
 1102        dID = self.resolveDecision(decision)
 1103        return self.nodes[dID]['domain']
 1104
 1105    def allDecisionsInDomain(
 1106        self,
 1107        domain: base.Domain
 1108    ) -> Set[base.DecisionID]:
 1109        """
 1110        Returns the set of all `DecisionID`s for decisions in the
 1111        specified domain.
 1112        """
 1113        return set(dID for dID in self if self.nodes[dID]['domain'] == domain)
 1114
 1115    def destination(
 1116        self,
 1117        decision: base.AnyDecisionSpecifier,
 1118        transition: base.Transition
 1119    ) -> base.DecisionID:
 1120        """
 1121        Overrides base `UniqueExitsGraph.destination` to raise
 1122        `MissingDecisionError` or `MissingTransitionError` as
 1123        appropriate, and to work with an `AnyDecisionSpecifier`.
 1124        """
 1125        dID = self.resolveDecision(decision)
 1126        try:
 1127            return super().destination(dID, transition)
 1128        except KeyError:
 1129            raise MissingTransitionError(
 1130                f"Transition {transition!r} does not exist at decision"
 1131                f" {self.identityOf(dID)}."
 1132            )
 1133
 1134    def getDestination(
 1135        self,
 1136        decision: base.AnyDecisionSpecifier,
 1137        transition: base.Transition,
 1138        default: Any = None
 1139    ) -> Optional[base.DecisionID]:
 1140        """
 1141        Overrides base `UniqueExitsGraph.getDestination` with different
 1142        argument names, since those matter for the edit DSL.
 1143        """
 1144        dID = self.resolveDecision(decision)
 1145        return super().getDestination(dID, transition)
 1146
 1147    def destinationsFrom(
 1148        self,
 1149        decision: base.AnyDecisionSpecifier
 1150    ) -> Dict[base.Transition, base.DecisionID]:
 1151        """
 1152        Override that just changes the type of the exception from a
 1153        `KeyError` to a `MissingDecisionError` when the source does not
 1154        exist.
 1155        """
 1156        dID = self.resolveDecision(decision)
 1157        return super().destinationsFrom(dID)
 1158
 1159    def bothEnds(
 1160        self,
 1161        decision: base.AnyDecisionSpecifier,
 1162        transition: base.Transition
 1163    ) -> Set[base.DecisionID]:
 1164        """
 1165        Returns a set containing the `DecisionID`(s) for both the start
 1166        and end of the specified transition. Raises a
 1167        `MissingDecisionError` or `MissingTransitionError`if the
 1168        specified decision and/or transition do not exist.
 1169
 1170        Note that for actions since the source and destination are the
 1171        same, the set will have only one element.
 1172        """
 1173        dID = self.resolveDecision(decision)
 1174        result = {dID}
 1175        dest = self.destination(dID, transition)
 1176        if dest is not None:
 1177            result.add(dest)
 1178        return result
 1179
 1180    def decisionActions(
 1181        self,
 1182        decision: base.AnyDecisionSpecifier
 1183    ) -> Set[base.Transition]:
 1184        """
 1185        Retrieves the set of self-edges at a decision. Editing the set
 1186        will not affect the graph.
 1187
 1188        Example:
 1189
 1190        >>> g = DecisionGraph()
 1191        >>> g.addDecision('A')
 1192        0
 1193        >>> g.addDecision('B')
 1194        1
 1195        >>> g.addDecision('C')
 1196        2
 1197        >>> g.addAction('A', 'action1')
 1198        >>> g.addAction('A', 'action2')
 1199        >>> g.addAction('B', 'action3')
 1200        >>> sorted(g.decisionActions('A'))
 1201        ['action1', 'action2']
 1202        >>> g.decisionActions('B')
 1203        {'action3'}
 1204        >>> g.decisionActions('C')
 1205        set()
 1206        """
 1207        result = set()
 1208        dID = self.resolveDecision(decision)
 1209        for transition, dest in self.destinationsFrom(dID).items():
 1210            if dest == dID:
 1211                result.add(transition)
 1212        return result
 1213
 1214    def getTransitionProperties(
 1215        self,
 1216        decision: base.AnyDecisionSpecifier,
 1217        transition: base.Transition
 1218    ) -> TransitionProperties:
 1219        """
 1220        Returns a dictionary containing transition properties for the
 1221        specified transition from the specified decision. The properties
 1222        included are:
 1223
 1224        - 'requirement': The requirement for the transition.
 1225        - 'consequence': Any consequence of the transition.
 1226        - 'tags': Any tags applied to the transition.
 1227        - 'annotations': Any annotations on the transition.
 1228
 1229        The reciprocal of the transition is not included.
 1230
 1231        The result is a clone of the stored properties; edits to the
 1232        dictionary will NOT modify the graph.
 1233        """
 1234        dID = self.resolveDecision(decision)
 1235        dest = self.destination(dID, transition)
 1236
 1237        info: TransitionProperties = copy.deepcopy(
 1238            self.edges[dID, dest, transition]  # type:ignore
 1239        )
 1240        return {
 1241            'requirement': info.get('requirement', base.ReqNothing()),
 1242            'consequence': info.get('consequence', []),
 1243            'tags': info.get('tags', {}),
 1244            'annotations': info.get('annotations', [])
 1245        }
 1246
 1247    def setTransitionProperties(
 1248        self,
 1249        decision: base.AnyDecisionSpecifier,
 1250        transition: base.Transition,
 1251        requirement: Optional[base.Requirement] = None,
 1252        consequence: Optional[base.Consequence] = None,
 1253        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 1254        annotations: Optional[List[base.Annotation]] = None
 1255    ) -> None:
 1256        """
 1257        Sets one or more transition properties all at once. Can be used
 1258        to set the requirement, consequence, tags, and/or annotations.
 1259        Old values are overwritten, although if `None`s are provided (or
 1260        arguments are omitted), corresponding properties are not
 1261        updated.
 1262
 1263        To add tags or annotations to existing tags/annotations instead
 1264        of replacing them, use `tagTransition` or `annotateTransition`
 1265        instead.
 1266        """
 1267        dID = self.resolveDecision(decision)
 1268        if requirement is not None:
 1269            self.setTransitionRequirement(dID, transition, requirement)
 1270        if consequence is not None:
 1271            self.setConsequence(dID, transition, consequence)
 1272        if tags is not None:
 1273            dest = self.destination(dID, transition)
 1274            # TODO: Submit pull request to update MultiDiGraph stubs in
 1275            # types-networkx to include OutMultiEdgeView that accepts
 1276            # from/to/key tuples as indices.
 1277            info = cast(
 1278                TransitionProperties,
 1279                self.edges[dID, dest, transition]  # type:ignore
 1280            )
 1281            info['tags'] = tags
 1282        if annotations is not None:
 1283            dest = self.destination(dID, transition)
 1284            info = cast(
 1285                TransitionProperties,
 1286                self.edges[dID, dest, transition]  # type:ignore
 1287            )
 1288            info['annotations'] = annotations
 1289
 1290    def getTransitionRequirement(
 1291        self,
 1292        decision: base.AnyDecisionSpecifier,
 1293        transition: base.Transition
 1294    ) -> base.Requirement:
 1295        """
 1296        Returns the `Requirement` for accessing a specific transition at
 1297        a specific decision. For transitions which don't have
 1298        requirements, returns a `ReqNothing` instance.
 1299        """
 1300        dID = self.resolveDecision(decision)
 1301        dest = self.destination(dID, transition)
 1302
 1303        info = cast(
 1304            TransitionProperties,
 1305            self.edges[dID, dest, transition]  # type:ignore
 1306        )
 1307
 1308        return info.get('requirement', base.ReqNothing())
 1309
 1310    def setTransitionRequirement(
 1311        self,
 1312        decision: base.AnyDecisionSpecifier,
 1313        transition: base.Transition,
 1314        requirement: Optional[base.Requirement]
 1315    ) -> None:
 1316        """
 1317        Sets the `Requirement` for accessing a specific transition at
 1318        a specific decision. Raises a `KeyError` if the decision or
 1319        transition does not exist.
 1320
 1321        Deletes the requirement if `None` is given as the requirement.
 1322
 1323        Use `parsing.ParseFormat.parseRequirement` first if you have a
 1324        requirement in string format.
 1325
 1326        Does not raise an error if deletion is requested for a
 1327        non-existent requirement, and silently overwrites any previous
 1328        requirement.
 1329        """
 1330        dID = self.resolveDecision(decision)
 1331
 1332        dest = self.destination(dID, transition)
 1333
 1334        info = cast(
 1335            TransitionProperties,
 1336            self.edges[dID, dest, transition]  # type:ignore
 1337        )
 1338
 1339        if requirement is None:
 1340            try:
 1341                del info['requirement']
 1342            except KeyError:
 1343                pass
 1344        else:
 1345            if not isinstance(requirement, base.Requirement):
 1346                raise TypeError(
 1347                    f"Invalid requirement type: {type(requirement)}"
 1348                )
 1349
 1350            info['requirement'] = requirement
 1351
 1352    def getConsequence(
 1353        self,
 1354        decision: base.AnyDecisionSpecifier,
 1355        transition: base.Transition
 1356    ) -> base.Consequence:
 1357        """
 1358        Retrieves the consequence of a transition.
 1359
 1360        A `KeyError` is raised if the specified decision/transition
 1361        combination doesn't exist.
 1362        """
 1363        dID = self.resolveDecision(decision)
 1364
 1365        dest = self.destination(dID, transition)
 1366
 1367        info = cast(
 1368            TransitionProperties,
 1369            self.edges[dID, dest, transition]  # type:ignore
 1370        )
 1371
 1372        return info.get('consequence', [])
 1373
 1374    def addConsequence(
 1375        self,
 1376        decision: base.AnyDecisionSpecifier,
 1377        transition: base.Transition,
 1378        consequence: base.Consequence
 1379    ) -> Tuple[int, int]:
 1380        """
 1381        Adds the given `Consequence` to the consequence list for the
 1382        specified transition, extending that list at the end. Note that
 1383        this does NOT make a copy of the consequence, so it should not
 1384        be used to copy consequences from one transition to another
 1385        without making a deep copy first.
 1386
 1387        A `MissingDecisionError` or a `MissingTransitionError` is raised
 1388        if the specified decision/transition combination doesn't exist.
 1389
 1390        Returns a pair of integers indicating the minimum and maximum
 1391        depth-first-traversal-indices of the added consequence part(s).
 1392        The outer consequence list itself (index 0) is not counted.
 1393
 1394        >>> d = DecisionGraph()
 1395        >>> d.addDecision('A')
 1396        0
 1397        >>> d.addDecision('B')
 1398        1
 1399        >>> d.addTransition('A', 'fwd', 'B', 'rev')
 1400        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
 1401        (1, 1)
 1402        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
 1403        (1, 1)
 1404        >>> ef = d.getConsequence('A', 'fwd')
 1405        >>> er = d.getConsequence('B', 'rev')
 1406        >>> ef == [base.effect(gain='sword')]
 1407        True
 1408        >>> er == [base.effect(lose='sword')]
 1409        True
 1410        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
 1411        (2, 2)
 1412        >>> ef = d.getConsequence('A', 'fwd')
 1413        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
 1414        True
 1415        >>> d.addConsequence(
 1416        ...     'A',
 1417        ...     'fwd',  # adding to consequence with 3 parts already
 1418        ...     [  # outer list not counted because it merges
 1419        ...         base.challenge(  # 1 part
 1420        ...             None,
 1421        ...             0,
 1422        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
 1423        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
 1424        ...         )
 1425        ...     ]
 1426        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
 1427        (3, 7)
 1428        """
 1429        dID = self.resolveDecision(decision)
 1430
 1431        dest = self.destination(dID, transition)
 1432
 1433        info = cast(
 1434            TransitionProperties,
 1435            self.edges[dID, dest, transition]  # type:ignore
 1436        )
 1437
 1438        existing = info.setdefault('consequence', [])
 1439        startIndex = base.countParts(existing)
 1440        existing.extend(consequence)
 1441        endIndex = base.countParts(existing) - 1
 1442        return (startIndex, endIndex)
 1443
 1444    def setConsequence(
 1445        self,
 1446        decision: base.AnyDecisionSpecifier,
 1447        transition: base.Transition,
 1448        consequence: base.Consequence
 1449    ) -> None:
 1450        """
 1451        Replaces the transition consequence for the given transition at
 1452        the given decision. Any previous consequence is discarded. See
 1453        `Consequence` for the structure of these. Note that this does
 1454        NOT make a copy of the consequence, do that first to avoid
 1455        effect-entanglement if you're copying a consequence.
 1456
 1457        A `MissingDecisionError` or a `MissingTransitionError` is raised
 1458        if the specified decision/transition combination doesn't exist.
 1459        """
 1460        dID = self.resolveDecision(decision)
 1461
 1462        dest = self.destination(dID, transition)
 1463
 1464        info = cast(
 1465            TransitionProperties,
 1466            self.edges[dID, dest, transition]  # type:ignore
 1467        )
 1468
 1469        info['consequence'] = consequence
 1470
 1471    def addEquivalence(
 1472        self,
 1473        requirement: base.Requirement,
 1474        capabilityOrMechanismState: Union[
 1475            base.Capability,
 1476            Tuple[base.MechanismID, base.MechanismState]
 1477        ]
 1478    ) -> None:
 1479        """
 1480        Adds the given requirement as an equivalence for the given
 1481        capability or the given mechanism state. Note that having a
 1482        capability via an equivalence does not count as actually having
 1483        that capability; it only counts for the purpose of satisfying
 1484        `Requirement`s.
 1485
 1486        Note also that because a mechanism-based requirement looks up
 1487        the specific mechanism locally based on a name, an equivalence
 1488        defined in one location may affect mechanism requirements in
 1489        other locations unless the mechanism name in the requirement is
 1490        zone-qualified to be specific. But in such situations the base
 1491        mechanism would have caused issues in any case.
 1492        """
 1493        self.equivalences.setdefault(
 1494            capabilityOrMechanismState,
 1495            set()
 1496        ).add(requirement)
 1497
 1498    def removeEquivalence(
 1499        self,
 1500        requirement: base.Requirement,
 1501        capabilityOrMechanismState: Union[
 1502            base.Capability,
 1503            Tuple[base.MechanismID, base.MechanismState]
 1504        ]
 1505    ) -> None:
 1506        """
 1507        Removes an equivalence. Raises a `KeyError` if no such
 1508        equivalence existed.
 1509        """
 1510        self.equivalences[capabilityOrMechanismState].remove(requirement)
 1511
 1512    def hasAnyEquivalents(
 1513        self,
 1514        capabilityOrMechanismState: Union[
 1515            base.Capability,
 1516            Tuple[base.MechanismID, base.MechanismState]
 1517        ]
 1518    ) -> bool:
 1519        """
 1520        Returns `True` if the given capability or mechanism state has at
 1521        least one equivalence.
 1522        """
 1523        return capabilityOrMechanismState in self.equivalences
 1524
 1525    def allEquivalents(
 1526        self,
 1527        capabilityOrMechanismState: Union[
 1528            base.Capability,
 1529            Tuple[base.MechanismID, base.MechanismState]
 1530        ]
 1531    ) -> Set[base.Requirement]:
 1532        """
 1533        Returns the set of equivalences for the given capability. This is
 1534        a live set which may be modified (it's probably better to use
 1535        `addEquivalence` and `removeEquivalence` instead...).
 1536        """
 1537        return self.equivalences.setdefault(
 1538            capabilityOrMechanismState,
 1539            set()
 1540        )
 1541
 1542    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
 1543        """
 1544        Specifies a new reversion type, so that when used in a reversion
 1545        aspects set with a colon before the name, all items in the
 1546        `equivalentTo` value will be added to that set. These may
 1547        include other custom reversion type names (with the colon) but
 1548        take care not to create an equivalence loop which would result
 1549        in a crash.
 1550
 1551        If you re-use the same name, it will override the old equivalence
 1552        for that name.
 1553        """
 1554        self.reversionTypes[name] = equivalentTo
 1555
 1556    def addAction(
 1557        self,
 1558        decision: base.AnyDecisionSpecifier,
 1559        action: base.Transition,
 1560        requires: Optional[base.Requirement] = None,
 1561        consequence: Optional[base.Consequence] = None,
 1562        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 1563        annotations: Optional[List[base.Annotation]] = None,
 1564    ) -> None:
 1565        """
 1566        Adds the given action as a possibility at the given decision. An
 1567        action is just a self-edge, which can have requirements like any
 1568        edge, and which can have consequences like any edge.
 1569        The optional arguments are given to `setTransitionRequirement`
 1570        and `setConsequence`; see those functions for descriptions
 1571        of what they mean.
 1572
 1573        Raises a `KeyError` if a transition with the given name already
 1574        exists at the given decision.
 1575        """
 1576        if tags is None:
 1577            tags = {}
 1578        if annotations is None:
 1579            annotations = []
 1580
 1581        dID = self.resolveDecision(decision)
 1582
 1583        self.add_edge(
 1584            dID,
 1585            dID,
 1586            key=action,
 1587            tags=tags,
 1588            annotations=annotations
 1589        )
 1590        self.setTransitionRequirement(dID, action, requires)
 1591        if consequence is not None:
 1592            self.setConsequence(dID, action, consequence)
 1593
 1594    def tagDecision(
 1595        self,
 1596        decision: base.AnyDecisionSpecifier,
 1597        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 1598        tagValue: Union[
 1599            base.TagValue,
 1600            type[base.NoTagValue]
 1601        ] = base.NoTagValue
 1602    ) -> None:
 1603        """
 1604        Adds a tag (or many tags from a dictionary of tags) to a
 1605        decision, using `1` as the value if no value is provided. It's
 1606        a `ValueError` to provide a value when a dictionary of tags is
 1607        provided to set multiple tags at once.
 1608
 1609        Note that certain tags have special meanings:
 1610
 1611        - 'unconfirmed' is used for decisions that represent unconfirmed
 1612            parts of the graph (this is separate from the 'unknown'
 1613            and/or 'hypothesized' exploration statuses, which are only
 1614            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
 1615            Various methods require this tag and many also add or remove
 1616            it.
 1617        """
 1618        if isinstance(tagOrTags, base.Tag):
 1619            if tagValue is base.NoTagValue:
 1620                tagValue = 1
 1621
 1622            # Not sure why this cast is necessary given the `if` above...
 1623            tagValue = cast(base.TagValue, tagValue)
 1624
 1625            tagOrTags = {tagOrTags: tagValue}
 1626
 1627        elif tagValue is not base.NoTagValue:
 1628            raise ValueError(
 1629                "Provided a dictionary to update multiple tags, but"
 1630                " also a tag value."
 1631            )
 1632
 1633        dID = self.resolveDecision(decision)
 1634
 1635        tagsAlready = self.nodes[dID].setdefault('tags', {})
 1636        tagsAlready.update(tagOrTags)
 1637
 1638    def untagDecision(
 1639        self,
 1640        decision: base.AnyDecisionSpecifier,
 1641        tag: base.Tag
 1642    ) -> Union[base.TagValue, type[base.NoTagValue]]:
 1643        """
 1644        Removes a tag from a decision. Returns the tag's old value if
 1645        the tag was present and got removed, or `NoTagValue` if the tag
 1646        wasn't present.
 1647        """
 1648        dID = self.resolveDecision(decision)
 1649
 1650        target = self.nodes[dID]['tags']
 1651        try:
 1652            return target.pop(tag)
 1653        except KeyError:
 1654            return base.NoTagValue
 1655
 1656    def decisionTags(
 1657        self,
 1658        decision: base.AnyDecisionSpecifier
 1659    ) -> Dict[base.Tag, base.TagValue]:
 1660        """
 1661        Returns the dictionary of tags for a decision. Edits to the
 1662        returned value will be applied to the graph.
 1663        """
 1664        dID = self.resolveDecision(decision)
 1665
 1666        return self.nodes[dID]['tags']
 1667
 1668    def annotateDecision(
 1669        self,
 1670        decision: base.AnyDecisionSpecifier,
 1671        annotationOrAnnotations: Union[
 1672            base.Annotation,
 1673            Sequence[base.Annotation]
 1674        ]
 1675    ) -> None:
 1676        """
 1677        Adds an annotation to a decision's annotations list.
 1678        """
 1679        dID = self.resolveDecision(decision)
 1680
 1681        if isinstance(annotationOrAnnotations, base.Annotation):
 1682            annotationOrAnnotations = [annotationOrAnnotations]
 1683        self.nodes[dID]['annotations'].extend(annotationOrAnnotations)
 1684
 1685    def decisionAnnotations(
 1686        self,
 1687        decision: base.AnyDecisionSpecifier
 1688    ) -> List[base.Annotation]:
 1689        """
 1690        Returns the list of annotations for the specified decision.
 1691        Modifying the list affects the graph.
 1692        """
 1693        dID = self.resolveDecision(decision)
 1694
 1695        return self.nodes[dID]['annotations']
 1696
 1697    def tagTransition(
 1698        self,
 1699        decision: base.AnyDecisionSpecifier,
 1700        transition: base.Transition,
 1701        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 1702        tagValue: Union[
 1703            base.TagValue,
 1704            type[base.NoTagValue]
 1705        ] = base.NoTagValue
 1706    ) -> None:
 1707        """
 1708        Adds a tag (or each tag from a dictionary) to a transition
 1709        coming out of a specific decision. `1` will be used as the
 1710        default value if a single tag is supplied; supplying a tag value
 1711        when providing a dictionary of multiple tags to update is a
 1712        `ValueError`.
 1713
 1714        Note that certain transition tags have special meanings:
 1715        - 'trigger' causes any actions (but not normal transitions) that
 1716            it applies to to be automatically triggered when
 1717            `advanceSituation` is called and the decision they're
 1718            attached to is active in the new situation (as long as the
 1719            action's requirements are met). This happens once per
 1720            situation; use 'wait' steps to re-apply triggers.
 1721        """
 1722        dID = self.resolveDecision(decision)
 1723
 1724        dest = self.destination(dID, transition)
 1725        if isinstance(tagOrTags, base.Tag):
 1726            if tagValue is base.NoTagValue:
 1727                tagValue = 1
 1728
 1729            # Not sure why this is necessary given the `if` above...
 1730            tagValue = cast(base.TagValue, tagValue)
 1731
 1732            tagOrTags = {tagOrTags: tagValue}
 1733        elif tagValue is not base.NoTagValue:
 1734            raise ValueError(
 1735                "Provided a dictionary to update multiple tags, but"
 1736                " also a tag value."
 1737            )
 1738
 1739        info = cast(
 1740            TransitionProperties,
 1741            self.edges[dID, dest, transition]  # type:ignore
 1742        )
 1743
 1744        info.setdefault('tags', {}).update(tagOrTags)
 1745
 1746    def untagTransition(
 1747        self,
 1748        decision: base.AnyDecisionSpecifier,
 1749        transition: base.Transition,
 1750        tagOrTags: Union[base.Tag, Set[base.Tag]]
 1751    ) -> None:
 1752        """
 1753        Removes a tag (or each tag in a set) from a transition coming out
 1754        of a specific decision. Raises a `KeyError` if (one of) the
 1755        specified tag(s) is not currently applied to the specified
 1756        transition.
 1757        """
 1758        dID = self.resolveDecision(decision)
 1759
 1760        dest = self.destination(dID, transition)
 1761        if isinstance(tagOrTags, base.Tag):
 1762            tagOrTags = {tagOrTags}
 1763
 1764        info = cast(
 1765            TransitionProperties,
 1766            self.edges[dID, dest, transition]  # type:ignore
 1767        )
 1768        tagsAlready = info.setdefault('tags', {})
 1769
 1770        for tag in tagOrTags:
 1771            tagsAlready.pop(tag)
 1772
 1773    def transitionTags(
 1774        self,
 1775        decision: base.AnyDecisionSpecifier,
 1776        transition: base.Transition
 1777    ) -> Dict[base.Tag, base.TagValue]:
 1778        """
 1779        Returns the dictionary of tags for a transition. Edits to the
 1780        returned dictionary will be applied to the graph.
 1781        """
 1782        dID = self.resolveDecision(decision)
 1783
 1784        dest = self.destination(dID, transition)
 1785        info = cast(
 1786            TransitionProperties,
 1787            self.edges[dID, dest, transition]  # type:ignore
 1788        )
 1789        return info.setdefault('tags', {})
 1790
 1791    def annotateTransition(
 1792        self,
 1793        decision: base.AnyDecisionSpecifier,
 1794        transition: base.Transition,
 1795        annotations: Union[base.Annotation, Sequence[base.Annotation]]
 1796    ) -> None:
 1797        """
 1798        Adds an annotation (or a sequence of annotations) to a
 1799        transition's annotations list.
 1800        """
 1801        dID = self.resolveDecision(decision)
 1802
 1803        dest = self.destination(dID, transition)
 1804        if isinstance(annotations, base.Annotation):
 1805            annotations = [annotations]
 1806        info = cast(
 1807            TransitionProperties,
 1808            self.edges[dID, dest, transition]  # type:ignore
 1809        )
 1810        info['annotations'].extend(annotations)
 1811
 1812    def transitionAnnotations(
 1813        self,
 1814        decision: base.AnyDecisionSpecifier,
 1815        transition: base.Transition
 1816    ) -> List[base.Annotation]:
 1817        """
 1818        Returns the annotation list for a specific transition at a
 1819        specific decision. Editing the list affects the graph.
 1820        """
 1821        dID = self.resolveDecision(decision)
 1822
 1823        dest = self.destination(dID, transition)
 1824        info = cast(
 1825            TransitionProperties,
 1826            self.edges[dID, dest, transition]  # type:ignore
 1827        )
 1828        return info['annotations']
 1829
 1830    def annotateZone(
 1831        self,
 1832        zone: base.Zone,
 1833        annotations: Union[base.Annotation, Sequence[base.Annotation]]
 1834    ) -> None:
 1835        """
 1836        Adds an annotation (or many annotations from a sequence) to a
 1837        zone.
 1838
 1839        Raises a `MissingZoneError` if the specified zone does not exist.
 1840        """
 1841        if zone not in self.zones:
 1842            raise MissingZoneError(
 1843                f"Can't add annotation(s) to zone {zone!r} because that"
 1844                f" zone doesn't exist yet."
 1845            )
 1846
 1847        if isinstance(annotations, base.Annotation):
 1848            annotations = [ annotations ]
 1849
 1850        self.zones[zone].annotations.extend(annotations)
 1851
 1852    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
 1853        """
 1854        Returns the list of annotations for the specified zone (empty if
 1855        none have been added yet).
 1856        """
 1857        return self.zones[zone].annotations
 1858
 1859    def tagZone(
 1860        self,
 1861        zone: base.Zone,
 1862        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 1863        tagValue: Union[
 1864            base.TagValue,
 1865            type[base.NoTagValue]
 1866        ] = base.NoTagValue
 1867    ) -> None:
 1868        """
 1869        Adds a tag (or many tags from a dictionary of tags) to a
 1870        zone, using `1` as the value if no value is provided. It's
 1871        a `ValueError` to provide a value when a dictionary of tags is
 1872        provided to set multiple tags at once.
 1873
 1874        Raises a `MissingZoneError` if the specified zone does not exist.
 1875        """
 1876        if zone not in self.zones:
 1877            raise MissingZoneError(
 1878                f"Can't add tag(s) to zone {zone!r} because that zone"
 1879                f" doesn't exist yet."
 1880            )
 1881
 1882        if isinstance(tagOrTags, base.Tag):
 1883            if tagValue is base.NoTagValue:
 1884                tagValue = 1
 1885
 1886            # Not sure why this cast is necessary given the `if` above...
 1887            tagValue = cast(base.TagValue, tagValue)
 1888
 1889            tagOrTags = {tagOrTags: tagValue}
 1890
 1891        elif tagValue is not base.NoTagValue:
 1892            raise ValueError(
 1893                "Provided a dictionary to update multiple tags, but"
 1894                " also a tag value."
 1895            )
 1896
 1897        tagsAlready = self.zones[zone].tags
 1898        tagsAlready.update(tagOrTags)
 1899
 1900    def untagZone(
 1901        self,
 1902        zone: base.Zone,
 1903        tag: base.Tag
 1904    ) -> Union[base.TagValue, type[base.NoTagValue]]:
 1905        """
 1906        Removes a tag from a zone. Returns the tag's old value if the
 1907        tag was present and got removed, or `NoTagValue` if the tag
 1908        wasn't present.
 1909
 1910        Raises a `MissingZoneError` if the specified zone does not exist.
 1911        """
 1912        if zone not in self.zones:
 1913            raise MissingZoneError(
 1914                f"Can't remove tag {tag!r} from zone {zone!r} because"
 1915                f" that zone doesn't exist yet."
 1916            )
 1917        target = self.zones[zone].tags
 1918        try:
 1919            return target.pop(tag)
 1920        except KeyError:
 1921            return base.NoTagValue
 1922
 1923    def zoneTags(
 1924        self,
 1925        zone: base.Zone
 1926    ) -> Dict[base.Tag, base.TagValue]:
 1927        """
 1928        Returns the dictionary of tags for a zone. Edits to the returned
 1929        value will be applied to the graph. Returns an empty tags
 1930        dictionary if called on a zone that didn't have any tags
 1931        previously, but raises a `MissingZoneError` if attempting to get
 1932        tags for a zone which does not exist.
 1933
 1934        For example:
 1935
 1936        >>> g = DecisionGraph()
 1937        >>> g.addDecision('A')
 1938        0
 1939        >>> g.addDecision('B')
 1940        1
 1941        >>> g.createZone('Zone')
 1942        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1943 annotations=[])
 1944        >>> g.tagZone('Zone', 'color', 'blue')
 1945        >>> g.tagZone(
 1946        ...     'Zone',
 1947        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
 1948        ... )
 1949        >>> g.untagZone('Zone', 'sound')
 1950        'loud'
 1951        >>> g.zoneTags('Zone')
 1952        {'color': 'red', 'shape': 'square'}
 1953        """
 1954        if zone in self.zones:
 1955            return self.zones[zone].tags
 1956        else:
 1957            raise MissingZoneError(
 1958                f"Tags for zone {zone!r} don't exist because that"
 1959                f" zone has not been created yet."
 1960            )
 1961
 1962    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
 1963        """
 1964        Creates an empty zone with the given name at the given level
 1965        (default 0). Raises a `ZoneCollisionError` if that zone name is
 1966        already in use (at any level), including if it's in use by a
 1967        decision.
 1968
 1969        Raises an `InvalidLevelError` if the level value is less than 0.
 1970
 1971        Returns the `ZoneInfo` for the new blank zone.
 1972
 1973        For example:
 1974
 1975        >>> d = DecisionGraph()
 1976        >>> d.createZone('Z', 0)
 1977        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1978 annotations=[])
 1979        >>> d.getZoneInfo('Z')
 1980        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1981 annotations=[])
 1982        >>> d.createZone('Z2', 0)
 1983        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 1984 annotations=[])
 1985        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
 1986        Traceback (most recent call last):
 1987        ...
 1988        exploration.core.InvalidLevelError...
 1989        >>> d.createZone('Z2')  # Name Z2 is already in use
 1990        Traceback (most recent call last):
 1991        ...
 1992        exploration.core.ZoneCollisionError...
 1993        """
 1994        if level < 0:
 1995            raise InvalidLevelError(
 1996                "Cannot create a zone with a negative level."
 1997            )
 1998        if zone in self.zones:
 1999            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
 2000        if zone in self:
 2001            raise ZoneCollisionError(
 2002                f"A decision named {zone!r} already exists, so a zone"
 2003                f" with that name cannot be created."
 2004            )
 2005        info: base.ZoneInfo = base.ZoneInfo(
 2006            level=level,
 2007            parents=set(),
 2008            contents=set(),
 2009            tags={},
 2010            annotations=[]
 2011        )
 2012        self.zones[zone] = info
 2013        return info
 2014
 2015    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
 2016        """
 2017        Returns the `ZoneInfo` (level, parents, and contents) for the
 2018        specified zone, or `None` if that zone does not exist.
 2019
 2020        For example:
 2021
 2022        >>> d = DecisionGraph()
 2023        >>> d.createZone('Z', 0)
 2024        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2025 annotations=[])
 2026        >>> d.getZoneInfo('Z')
 2027        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2028 annotations=[])
 2029        >>> d.createZone('Z2', 0)
 2030        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2031 annotations=[])
 2032        >>> d.getZoneInfo('Z2')
 2033        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2034 annotations=[])
 2035        """
 2036        return self.zones.get(zone)
 2037
 2038    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
 2039        """
 2040        Deletes the specified zone, returning a `ZoneInfo` object with
 2041        the information on the level, parents, and contents of that zone.
 2042
 2043        Raises a `MissingZoneError` if the zone in question does not
 2044        exist.
 2045
 2046        For example:
 2047
 2048        >>> d = DecisionGraph()
 2049        >>> d.createZone('Z', 0)
 2050        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2051 annotations=[])
 2052        >>> d.getZoneInfo('Z')
 2053        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2054 annotations=[])
 2055        >>> d.deleteZone('Z')
 2056        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2057 annotations=[])
 2058        >>> d.getZoneInfo('Z') is None  # no info any more
 2059        True
 2060        >>> d.deleteZone('Z')  # can't re-delete
 2061        Traceback (most recent call last):
 2062        ...
 2063        exploration.core.MissingZoneError...
 2064        """
 2065        info = self.getZoneInfo(zone)
 2066        if info is None:
 2067            raise MissingZoneError(
 2068                f"Cannot delete zone {zone!r}: it does not exist."
 2069            )
 2070        for sub in info.contents:
 2071            if 'zones' in self.nodes[sub]:
 2072                try:
 2073                    self.nodes[sub]['zones'].remove(zone)
 2074                except KeyError:
 2075                    pass
 2076        del self.zones[zone]
 2077        return info
 2078
 2079    def addDecisionToZone(
 2080        self,
 2081        decision: base.AnyDecisionSpecifier,
 2082        zone: base.Zone
 2083    ) -> None:
 2084        """
 2085        Adds a decision directly to a zone. Should normally only be used
 2086        with level-0 zones. Raises a `MissingZoneError` if the specified
 2087        zone did not already exist.
 2088
 2089        For example:
 2090
 2091        >>> d = DecisionGraph()
 2092        >>> d.addDecision('A')
 2093        0
 2094        >>> d.addDecision('B')
 2095        1
 2096        >>> d.addDecision('C')
 2097        2
 2098        >>> d.createZone('Z', 0)
 2099        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2100 annotations=[])
 2101        >>> d.addDecisionToZone('A', 'Z')
 2102        >>> d.getZoneInfo('Z')
 2103        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
 2104 annotations=[])
 2105        >>> d.addDecisionToZone('B', 'Z')
 2106        >>> d.getZoneInfo('Z')
 2107        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2108 annotations=[])
 2109        """
 2110        dID = self.resolveDecision(decision)
 2111
 2112        if zone not in self.zones:
 2113            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2114
 2115        self.zones[zone].contents.add(dID)
 2116        self.nodes[dID].setdefault('zones', set()).add(zone)
 2117
 2118    def removeDecisionFromZone(
 2119        self,
 2120        decision: base.AnyDecisionSpecifier,
 2121        zone: base.Zone
 2122    ) -> bool:
 2123        """
 2124        Removes a decision from a zone if it had been in it, returning
 2125        True if that decision had been in that zone, and False if it was
 2126        not in that zone, including if that zone didn't exist.
 2127
 2128        Note that this only removes a decision from direct zone
 2129        membership. If the decision is a member of one or more zones
 2130        which are (directly or indirectly) sub-zones of the target zone,
 2131        the decision will remain in those zones, and will still be
 2132        indirectly part of the target zone afterwards.
 2133
 2134        Examples:
 2135
 2136        >>> g = DecisionGraph()
 2137        >>> g.addDecision('A')
 2138        0
 2139        >>> g.addDecision('B')
 2140        1
 2141        >>> g.createZone('level0', 0)
 2142        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2143 annotations=[])
 2144        >>> g.createZone('level1', 1)
 2145        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2146 annotations=[])
 2147        >>> g.createZone('level2', 2)
 2148        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2149 annotations=[])
 2150        >>> g.createZone('level3', 3)
 2151        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2152 annotations=[])
 2153        >>> g.addDecisionToZone('A', 'level0')
 2154        >>> g.addDecisionToZone('B', 'level0')
 2155        >>> g.addZoneToZone('level0', 'level1')
 2156        >>> g.addZoneToZone('level1', 'level2')
 2157        >>> g.addZoneToZone('level2', 'level3')
 2158        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 2159        >>> g.removeDecisionFromZone('A', 'level1')
 2160        False
 2161        >>> g.zoneParents(0)
 2162        {'level0'}
 2163        >>> g.removeDecisionFromZone('A', 'level0')
 2164        True
 2165        >>> g.zoneParents(0)
 2166        set()
 2167        >>> g.removeDecisionFromZone('A', 'level0')
 2168        False
 2169        >>> g.removeDecisionFromZone('B', 'level0')
 2170        True
 2171        >>> g.zoneParents(1)
 2172        {'level2'}
 2173        >>> g.removeDecisionFromZone('B', 'level0')
 2174        False
 2175        >>> g.removeDecisionFromZone('B', 'level2')
 2176        True
 2177        >>> g.zoneParents(1)
 2178        set()
 2179        """
 2180        dID = self.resolveDecision(decision)
 2181
 2182        if zone not in self.zones:
 2183            return False
 2184
 2185        info = self.zones[zone]
 2186        if dID not in info.contents:
 2187            return False
 2188        else:
 2189            info.contents.remove(dID)
 2190            try:
 2191                self.nodes[dID]['zones'].remove(zone)
 2192            except KeyError:
 2193                pass
 2194            return True
 2195
 2196    def addZoneToZone(
 2197        self,
 2198        addIt: base.Zone,
 2199        addTo: base.Zone
 2200    ) -> None:
 2201        """
 2202        Adds a zone to another zone. The `addIt` one must be at a
 2203        strictly lower level than the `addTo` zone, or an
 2204        `InvalidLevelError` will be raised.
 2205
 2206        If the zone to be added didn't already exist, it is created at
 2207        one level below the target zone. Similarly, if the zone being
 2208        added to didn't already exist, it is created at one level above
 2209        the target zone. If neither existed, a `MissingZoneError` will
 2210        be raised.
 2211
 2212        For example:
 2213
 2214        >>> d = DecisionGraph()
 2215        >>> d.addDecision('A')
 2216        0
 2217        >>> d.addDecision('B')
 2218        1
 2219        >>> d.addDecision('C')
 2220        2
 2221        >>> d.createZone('Z', 0)
 2222        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2223 annotations=[])
 2224        >>> d.addDecisionToZone('A', 'Z')
 2225        >>> d.addDecisionToZone('B', 'Z')
 2226        >>> d.getZoneInfo('Z')
 2227        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2228 annotations=[])
 2229        >>> d.createZone('Z2', 0)
 2230        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2231 annotations=[])
 2232        >>> d.addDecisionToZone('B', 'Z2')
 2233        >>> d.addDecisionToZone('C', 'Z2')
 2234        >>> d.getZoneInfo('Z2')
 2235        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2236 annotations=[])
 2237        >>> d.createZone('l1Z', 1)
 2238        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2239 annotations=[])
 2240        >>> d.createZone('l2Z', 2)
 2241        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2242 annotations=[])
 2243        >>> d.addZoneToZone('Z', 'l1Z')
 2244        >>> d.getZoneInfo('Z')
 2245        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
 2246 annotations=[])
 2247        >>> d.getZoneInfo('l1Z')
 2248        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2249 annotations=[])
 2250        >>> d.addZoneToZone('l1Z', 'l2Z')
 2251        >>> d.getZoneInfo('l1Z')
 2252        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
 2253 annotations=[])
 2254        >>> d.getZoneInfo('l2Z')
 2255        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
 2256 annotations=[])
 2257        >>> d.addZoneToZone('Z2', 'l2Z')
 2258        >>> d.getZoneInfo('Z2')
 2259        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
 2260 annotations=[])
 2261        >>> l2i = d.getZoneInfo('l2Z')
 2262        >>> l2i.level
 2263        2
 2264        >>> l2i.parents
 2265        set()
 2266        >>> sorted(l2i.contents)
 2267        ['Z2', 'l1Z']
 2268        >>> d.addZoneToZone('NZ', 'NZ2')
 2269        Traceback (most recent call last):
 2270        ...
 2271        exploration.core.MissingZoneError...
 2272        >>> d.addZoneToZone('Z', 'l1Z2')
 2273        >>> zi = d.getZoneInfo('Z')
 2274        >>> zi.level
 2275        0
 2276        >>> sorted(zi.parents)
 2277        ['l1Z', 'l1Z2']
 2278        >>> sorted(zi.contents)
 2279        [0, 1]
 2280        >>> d.getZoneInfo('l1Z2')
 2281        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2282 annotations=[])
 2283        >>> d.addZoneToZone('NZ', 'l1Z')
 2284        >>> d.getZoneInfo('NZ')
 2285        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
 2286 annotations=[])
 2287        >>> zi = d.getZoneInfo('l1Z')
 2288        >>> zi.level
 2289        1
 2290        >>> zi.parents
 2291        {'l2Z'}
 2292        >>> sorted(zi.contents)
 2293        ['NZ', 'Z']
 2294        """
 2295        # Create one or the other (but not both) if they're missing
 2296        addInfo = self.getZoneInfo(addIt)
 2297        toInfo = self.getZoneInfo(addTo)
 2298        if addInfo is None and toInfo is None:
 2299            raise MissingZoneError(
 2300                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
 2301                f" exists already."
 2302            )
 2303
 2304        # Create missing addIt
 2305        elif addInfo is None:
 2306            toInfo = cast(base.ZoneInfo, toInfo)
 2307            newLevel = toInfo.level - 1
 2308            if newLevel < 0:
 2309                raise InvalidLevelError(
 2310                    f"Zone {addTo!r} is at level {toInfo.level} and so"
 2311                    f" a new zone cannot be added underneath it."
 2312                )
 2313            addInfo = self.createZone(addIt, newLevel)
 2314
 2315        # Create missing addTo
 2316        elif toInfo is None:
 2317            addInfo = cast(base.ZoneInfo, addInfo)
 2318            newLevel = addInfo.level + 1
 2319            if newLevel < 0:
 2320                raise InvalidLevelError(
 2321                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
 2322                    f" and so a new zone cannot be added above it."
 2323                )
 2324            toInfo = self.createZone(addTo, newLevel)
 2325
 2326        # Now both addInfo and toInfo are defined
 2327        if addInfo.level >= toInfo.level:
 2328            raise InvalidLevelError(
 2329                f"Cannot add zone {addIt!r} at level {addInfo.level}"
 2330                f" to zone {addTo!r} at level {toInfo.level}: zones can"
 2331                f" only contain zones of lower levels."
 2332            )
 2333
 2334        # Now both addInfo and toInfo are defined
 2335        toInfo.contents.add(addIt)
 2336        addInfo.parents.add(addTo)
 2337
 2338    def removeZoneFromZone(
 2339        self,
 2340        removeIt: base.Zone,
 2341        removeFrom: base.Zone
 2342    ) -> bool:
 2343        """
 2344        Removes a zone from a zone if it had been in it, returning True
 2345        if that zone had been in that zone, and False if it was not in
 2346        that zone, including if either zone did not exist.
 2347
 2348        For example:
 2349
 2350        >>> d = DecisionGraph()
 2351        >>> d.createZone('Z', 0)
 2352        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2353 annotations=[])
 2354        >>> d.createZone('Z2', 0)
 2355        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2356 annotations=[])
 2357        >>> d.createZone('l1Z', 1)
 2358        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2359 annotations=[])
 2360        >>> d.createZone('l2Z', 2)
 2361        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2362 annotations=[])
 2363        >>> d.addZoneToZone('Z', 'l1Z')
 2364        >>> d.addZoneToZone('l1Z', 'l2Z')
 2365        >>> d.getZoneInfo('Z')
 2366        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
 2367 annotations=[])
 2368        >>> d.getZoneInfo('l1Z')
 2369        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
 2370 annotations=[])
 2371        >>> d.getZoneInfo('l2Z')
 2372        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
 2373 annotations=[])
 2374        >>> d.removeZoneFromZone('l1Z', 'l2Z')
 2375        True
 2376        >>> d.getZoneInfo('l1Z')
 2377        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
 2378 annotations=[])
 2379        >>> d.getZoneInfo('l2Z')
 2380        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2381 annotations=[])
 2382        >>> d.removeZoneFromZone('Z', 'l1Z')
 2383        True
 2384        >>> d.getZoneInfo('Z')
 2385        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2386 annotations=[])
 2387        >>> d.getZoneInfo('l1Z')
 2388        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2389 annotations=[])
 2390        >>> d.removeZoneFromZone('Z', 'l1Z')
 2391        False
 2392        >>> d.removeZoneFromZone('Z', 'madeup')
 2393        False
 2394        >>> d.removeZoneFromZone('nope', 'madeup')
 2395        False
 2396        >>> d.removeZoneFromZone('nope', 'l1Z')
 2397        False
 2398        """
 2399        remInfo = self.getZoneInfo(removeIt)
 2400        fromInfo = self.getZoneInfo(removeFrom)
 2401
 2402        if remInfo is None or fromInfo is None:
 2403            return False
 2404
 2405        if removeIt not in fromInfo.contents:
 2406            return False
 2407
 2408        remInfo.parents.remove(removeFrom)
 2409        fromInfo.contents.remove(removeIt)
 2410        return True
 2411
 2412    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
 2413        """
 2414        Returns a set of all decisions included directly in the given
 2415        zone, not counting decisions included via intermediate
 2416        sub-zones (see `allDecisionsInZone` to include those).
 2417
 2418        Raises a `MissingZoneError` if the specified zone does not
 2419        exist.
 2420
 2421        The returned set is a copy, not a live editable set.
 2422
 2423        For example:
 2424
 2425        >>> d = DecisionGraph()
 2426        >>> d.addDecision('A')
 2427        0
 2428        >>> d.addDecision('B')
 2429        1
 2430        >>> d.addDecision('C')
 2431        2
 2432        >>> d.createZone('Z', 0)
 2433        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2434 annotations=[])
 2435        >>> d.addDecisionToZone('A', 'Z')
 2436        >>> d.addDecisionToZone('B', 'Z')
 2437        >>> d.getZoneInfo('Z')
 2438        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2439 annotations=[])
 2440        >>> d.decisionsInZone('Z')
 2441        {0, 1}
 2442        >>> d.createZone('Z2', 0)
 2443        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2444 annotations=[])
 2445        >>> d.addDecisionToZone('B', 'Z2')
 2446        >>> d.addDecisionToZone('C', 'Z2')
 2447        >>> d.getZoneInfo('Z2')
 2448        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2449 annotations=[])
 2450        >>> d.decisionsInZone('Z')
 2451        {0, 1}
 2452        >>> d.decisionsInZone('Z2')
 2453        {1, 2}
 2454        >>> d.createZone('l1Z', 1)
 2455        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2456 annotations=[])
 2457        >>> d.addZoneToZone('Z', 'l1Z')
 2458        >>> d.decisionsInZone('Z')
 2459        {0, 1}
 2460        >>> d.decisionsInZone('l1Z')
 2461        set()
 2462        >>> d.decisionsInZone('madeup')
 2463        Traceback (most recent call last):
 2464        ...
 2465        exploration.core.MissingZoneError...
 2466        >>> zDec = d.decisionsInZone('Z')
 2467        >>> zDec.add(2)  # won't affect the zone
 2468        >>> zDec
 2469        {0, 1, 2}
 2470        >>> d.decisionsInZone('Z')
 2471        {0, 1}
 2472        """
 2473        info = self.getZoneInfo(zone)
 2474        if info is None:
 2475            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2476
 2477        # Everything that's not a zone must be a decision
 2478        return {
 2479            item
 2480            for item in info.contents
 2481            if isinstance(item, base.DecisionID)
 2482        }
 2483
 2484    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
 2485        """
 2486        Returns the set of all immediate sub-zones of the given zone.
 2487        Will be an empty set if there are no sub-zones; raises a
 2488        `MissingZoneError` if the specified zone does not exit.
 2489
 2490        The returned set is a copy, not a live editable set.
 2491
 2492        For example:
 2493
 2494        >>> d = DecisionGraph()
 2495        >>> d.createZone('Z', 0)
 2496        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2497 annotations=[])
 2498        >>> d.subZones('Z')
 2499        set()
 2500        >>> d.createZone('l1Z', 1)
 2501        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2502 annotations=[])
 2503        >>> d.addZoneToZone('Z', 'l1Z')
 2504        >>> d.subZones('Z')
 2505        set()
 2506        >>> d.subZones('l1Z')
 2507        {'Z'}
 2508        >>> s = d.subZones('l1Z')
 2509        >>> s.add('Q')  # doesn't affect the zone
 2510        >>> sorted(s)
 2511        ['Q', 'Z']
 2512        >>> d.subZones('l1Z')
 2513        {'Z'}
 2514        >>> d.subZones('madeup')
 2515        Traceback (most recent call last):
 2516        ...
 2517        exploration.core.MissingZoneError...
 2518        """
 2519        info = self.getZoneInfo(zone)
 2520        if info is None:
 2521            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2522
 2523        # Sub-zones will appear in self.zones
 2524        return {
 2525            item
 2526            for item in info.contents
 2527            if isinstance(item, base.Zone)
 2528        }
 2529
 2530    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
 2531        """
 2532        Returns a set containing all decisions in the given zone,
 2533        including those included via sub-zones.
 2534
 2535        Raises a `MissingZoneError` if the specified zone does not
 2536        exist.`
 2537
 2538        For example:
 2539
 2540        >>> d = DecisionGraph()
 2541        >>> d.addDecision('A')
 2542        0
 2543        >>> d.addDecision('B')
 2544        1
 2545        >>> d.addDecision('C')
 2546        2
 2547        >>> d.createZone('Z', 0)
 2548        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2549 annotations=[])
 2550        >>> d.addDecisionToZone('A', 'Z')
 2551        >>> d.addDecisionToZone('B', 'Z')
 2552        >>> d.getZoneInfo('Z')
 2553        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
 2554 annotations=[])
 2555        >>> d.decisionsInZone('Z')
 2556        {0, 1}
 2557        >>> d.allDecisionsInZone('Z')
 2558        {0, 1}
 2559        >>> d.createZone('Z2', 0)
 2560        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2561 annotations=[])
 2562        >>> d.addDecisionToZone('B', 'Z2')
 2563        >>> d.addDecisionToZone('C', 'Z2')
 2564        >>> d.getZoneInfo('Z2')
 2565        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
 2566 annotations=[])
 2567        >>> d.decisionsInZone('Z')
 2568        {0, 1}
 2569        >>> d.decisionsInZone('Z2')
 2570        {1, 2}
 2571        >>> d.allDecisionsInZone('Z2')
 2572        {1, 2}
 2573        >>> d.createZone('l1Z', 1)
 2574        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2575 annotations=[])
 2576        >>> d.createZone('l2Z', 2)
 2577        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2578 annotations=[])
 2579        >>> d.addZoneToZone('Z', 'l1Z')
 2580        >>> d.addZoneToZone('l1Z', 'l2Z')
 2581        >>> d.addZoneToZone('Z2', 'l2Z')
 2582        >>> d.decisionsInZone('Z')
 2583        {0, 1}
 2584        >>> d.decisionsInZone('Z2')
 2585        {1, 2}
 2586        >>> d.decisionsInZone('l1Z')
 2587        set()
 2588        >>> d.allDecisionsInZone('l1Z')
 2589        {0, 1}
 2590        >>> d.allDecisionsInZone('l2Z')
 2591        {0, 1, 2}
 2592        """
 2593        result: Set[base.DecisionID] = set()
 2594        info = self.getZoneInfo(zone)
 2595        if info is None:
 2596            raise MissingZoneError(f"Zone {zone!r} does not exist.")
 2597
 2598        for item in info.contents:
 2599            if isinstance(item, base.Zone):
 2600                # This can't be an error because of the condition above
 2601                result |= self.allDecisionsInZone(item)
 2602            else:  # it's a decision
 2603                result.add(item)
 2604
 2605        return result
 2606
 2607    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
 2608        """
 2609        Returns the hierarchy level of the given zone, as stored in its
 2610        zone info.
 2611
 2612        Raises a `MissingZoneError` if the specified zone does not
 2613        exist.
 2614
 2615        For example:
 2616
 2617        >>> d = DecisionGraph()
 2618        >>> d.createZone('Z', 0)
 2619        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2620 annotations=[])
 2621        >>> d.createZone('l1Z', 1)
 2622        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2623 annotations=[])
 2624        >>> d.createZone('l5Z', 5)
 2625        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
 2626 annotations=[])
 2627        >>> d.zoneHierarchyLevel('Z')
 2628        0
 2629        >>> d.zoneHierarchyLevel('l1Z')
 2630        1
 2631        >>> d.zoneHierarchyLevel('l5Z')
 2632        5
 2633        >>> d.zoneHierarchyLevel('madeup')
 2634        Traceback (most recent call last):
 2635        ...
 2636        exploration.core.MissingZoneError...
 2637        """
 2638        info = self.getZoneInfo(zone)
 2639        if info is None:
 2640            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
 2641
 2642        return info.level
 2643
 2644    def zoneParents(
 2645        self,
 2646        zoneOrDecision: Union[base.Zone, base.DecisionID]
 2647    ) -> Set[base.Zone]:
 2648        """
 2649        Returns the set of all zones which directly contain the target
 2650        zone or decision.
 2651
 2652        Raises a `MissingDecisionError` if the target is neither a valid
 2653        zone nor a valid decision.
 2654
 2655        Returns a copy, not a live editable set.
 2656
 2657        Example:
 2658
 2659        >>> g = DecisionGraph()
 2660        >>> g.addDecision('A')
 2661        0
 2662        >>> g.addDecision('B')
 2663        1
 2664        >>> g.createZone('level0', 0)
 2665        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2666 annotations=[])
 2667        >>> g.createZone('level1', 1)
 2668        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2669 annotations=[])
 2670        >>> g.createZone('level2', 2)
 2671        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2672 annotations=[])
 2673        >>> g.createZone('level3', 3)
 2674        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2675 annotations=[])
 2676        >>> g.addDecisionToZone('A', 'level0')
 2677        >>> g.addDecisionToZone('B', 'level0')
 2678        >>> g.addZoneToZone('level0', 'level1')
 2679        >>> g.addZoneToZone('level1', 'level2')
 2680        >>> g.addZoneToZone('level2', 'level3')
 2681        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 2682        >>> sorted(g.zoneParents(0))
 2683        ['level0']
 2684        >>> sorted(g.zoneParents(1))
 2685        ['level0', 'level2']
 2686        """
 2687        if zoneOrDecision in self.zones:
 2688            zoneOrDecision = cast(base.Zone, zoneOrDecision)
 2689            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
 2690            return copy.copy(info.parents)
 2691        elif zoneOrDecision in self:
 2692            return self.nodes[zoneOrDecision].get('zones', set())
 2693        else:
 2694            raise MissingDecisionError(
 2695                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
 2696                f" valid decision."
 2697            )
 2698
 2699    def zoneAncestors(
 2700        self,
 2701        zoneOrDecision: Union[base.Zone, base.DecisionID],
 2702        exclude: Set[base.Zone] = set()
 2703    ) -> Set[base.Zone]:
 2704        """
 2705        Returns the set of zones which contain the target zone or
 2706        decision, either directly or indirectly. The target is not
 2707        included in the set.
 2708
 2709        Any ones listed in the `exclude` set are also excluded, as are
 2710        any of their ancestors which are not also ancestors of the
 2711        target zone via another path of inclusion.
 2712
 2713        Raises a `MissingDecisionError` if the target is nether a valid
 2714        zone nor a valid decision.
 2715
 2716        Example:
 2717
 2718        >>> g = DecisionGraph()
 2719        >>> g.addDecision('A')
 2720        0
 2721        >>> g.addDecision('B')
 2722        1
 2723        >>> g.createZone('level0', 0)
 2724        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2725 annotations=[])
 2726        >>> g.createZone('level1', 1)
 2727        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2728 annotations=[])
 2729        >>> g.createZone('level2', 2)
 2730        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2731 annotations=[])
 2732        >>> g.createZone('level3', 3)
 2733        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2734 annotations=[])
 2735        >>> g.addDecisionToZone('A', 'level0')
 2736        >>> g.addDecisionToZone('B', 'level0')
 2737        >>> g.addZoneToZone('level0', 'level1')
 2738        >>> g.addZoneToZone('level1', 'level2')
 2739        >>> g.addZoneToZone('level2', 'level3')
 2740        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
 2741        >>> sorted(g.zoneAncestors(0))
 2742        ['level0', 'level1', 'level2', 'level3']
 2743        >>> sorted(g.zoneAncestors(1))
 2744        ['level0', 'level1', 'level2', 'level3']
 2745        >>> sorted(g.zoneParents(0))
 2746        ['level0']
 2747        >>> sorted(g.zoneParents(1))
 2748        ['level0', 'level2']
 2749        """
 2750        # Copy is important here!
 2751        result = set(self.zoneParents(zoneOrDecision))
 2752        result -= exclude
 2753        for parent in copy.copy(result):
 2754            # Recursively dig up ancestors, but exclude
 2755            # results-so-far to avoid re-enumerating when there are
 2756            # multiple braided inclusion paths.
 2757            result |= self.zoneAncestors(parent, result | exclude)
 2758
 2759        return result
 2760
 2761    def zoneEdges(self, zone: base.Zone) -> Optional[
 2762        Tuple[
 2763            Set[Tuple[base.DecisionID, base.Transition]],
 2764            Set[Tuple[base.DecisionID, base.Transition]]
 2765        ]
 2766    ]:
 2767        """
 2768        Given a zone to look at, finds all of the transitions which go
 2769        out of and into that zone, ignoring internal transitions between
 2770        decisions in the zone. This includes all decisions in sub-zones.
 2771        The return value is a pair of sets for outgoing and then
 2772        incoming transitions, where each transition is specified as a
 2773        (sourceID, transitionName) pair.
 2774
 2775        Returns `None` if the target zone isn't yet fully defined.
 2776
 2777        Note that this takes time proportional to *all* edges plus *all*
 2778        nodes in the graph no matter how large or small the zone in
 2779        question is.
 2780
 2781        >>> g = DecisionGraph()
 2782        >>> g.addDecision('A')
 2783        0
 2784        >>> g.addDecision('B')
 2785        1
 2786        >>> g.addDecision('C')
 2787        2
 2788        >>> g.addDecision('D')
 2789        3
 2790        >>> g.addTransition('A', 'up', 'B', 'down')
 2791        >>> g.addTransition('B', 'right', 'C', 'left')
 2792        >>> g.addTransition('C', 'down', 'D', 'up')
 2793        >>> g.addTransition('D', 'left', 'A', 'right')
 2794        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
 2795        >>> g.createZone('Z', 0)
 2796        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2797 annotations=[])
 2798        >>> g.createZone('ZZ', 1)
 2799        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2800 annotations=[])
 2801        >>> g.addZoneToZone('Z', 'ZZ')
 2802        >>> g.addDecisionToZone('A', 'Z')
 2803        >>> g.addDecisionToZone('B', 'Z')
 2804        >>> g.addDecisionToZone('D', 'ZZ')
 2805        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
 2806        >>> sorted(outgoing)
 2807        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
 2808        >>> sorted(incoming)
 2809        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
 2810        >>> outgoing, incoming = g.zoneEdges('ZZ')
 2811        >>> sorted(outgoing)
 2812        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
 2813        >>> sorted(incoming)
 2814        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
 2815        >>> g.zoneEdges('madeup') is None
 2816        True
 2817        """
 2818        # Find the interior nodes
 2819        try:
 2820            interior = self.allDecisionsInZone(zone)
 2821        except MissingZoneError:
 2822            return None
 2823
 2824        # Set up our result
 2825        results: Tuple[
 2826            Set[Tuple[base.DecisionID, base.Transition]],
 2827            Set[Tuple[base.DecisionID, base.Transition]]
 2828        ] = (set(), set())
 2829
 2830        # Because finding incoming edges requires searching the entire
 2831        # graph anyways, it's more efficient to just consider each edge
 2832        # once.
 2833        for fromDecision in self:
 2834            fromThere = self[fromDecision]
 2835            for toDecision in fromThere:
 2836                for transition in fromThere[toDecision]:
 2837                    sourceIn = fromDecision in interior
 2838                    destIn = toDecision in interior
 2839                    if sourceIn and not destIn:
 2840                        results[0].add((fromDecision, transition))
 2841                    elif destIn and not sourceIn:
 2842                        results[1].add((fromDecision, transition))
 2843
 2844        return results
 2845
 2846    def replaceZonesInHierarchy(
 2847        self,
 2848        target: base.AnyDecisionSpecifier,
 2849        zone: base.Zone,
 2850        level: int
 2851    ) -> None:
 2852        """
 2853        This method replaces one or more zones which contain the
 2854        specified `target` decision with a specific zone, at a specific
 2855        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
 2856        named zone doesn't yet exist, it will be created.
 2857
 2858        To do this, it looks at all zones which contain the target
 2859        decision directly or indirectly (see `zoneAncestors`) and which
 2860        are at the specified level.
 2861
 2862        - Any direct children of those zones which are ancestors of the
 2863            target decision are removed from those zones and placed into
 2864            the new zone instead, regardless of their levels. Indirect
 2865            children are not affected (except perhaps indirectly via
 2866            their parents' ancestors changing).
 2867        - The new zone is placed into every direct parent of those
 2868            zones, regardless of their levels (those parents are by
 2869            definition all ancestors of the target decision).
 2870        - If there were no zones at the target level, every zone at the
 2871            next level down which is an ancestor of the target decision
 2872            (or just that decision if the level is 0) is placed into the
 2873            new zone as a direct child (and is removed from any previous
 2874            parents it had). In this case, the new zone will also be
 2875            added as a sub-zone to every ancestor of the target decision
 2876            at the level above the specified level, if there are any.
 2877            * In this case, if there are no zones at the level below the
 2878                specified level, the highest level of zones smaller than
 2879                that is treated as the level below, down to targeting
 2880                the decision itself.
 2881            * Similarly, if there are no zones at the level above the
 2882                specified level but there are zones at a higher level,
 2883                the new zone will be added to each of the zones in the
 2884                lowest level above the target level that has zones in it.
 2885
 2886        A `MissingDecisionError` will be raised if the specified
 2887        decision is not valid, or if the decision is left as default but
 2888        there is no current decision in the exploration.
 2889
 2890        An `InvalidLevelError` will be raised if the level is less than
 2891        zero.
 2892
 2893        Example:
 2894
 2895        >>> g = DecisionGraph()
 2896        >>> g.addDecision('decision')
 2897        0
 2898        >>> g.addDecision('alternate')
 2899        1
 2900        >>> g.createZone('zone0', 0)
 2901        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 2902 annotations=[])
 2903        >>> g.createZone('zone1', 1)
 2904        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 2905 annotations=[])
 2906        >>> g.createZone('zone2.1', 2)
 2907        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2908 annotations=[])
 2909        >>> g.createZone('zone2.2', 2)
 2910        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 2911 annotations=[])
 2912        >>> g.createZone('zone3', 3)
 2913        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 2914 annotations=[])
 2915        >>> g.addDecisionToZone('decision', 'zone0')
 2916        >>> g.addDecisionToZone('alternate', 'zone0')
 2917        >>> g.addZoneToZone('zone0', 'zone1')
 2918        >>> g.addZoneToZone('zone1', 'zone2.1')
 2919        >>> g.addZoneToZone('zone1', 'zone2.2')
 2920        >>> g.addZoneToZone('zone2.1', 'zone3')
 2921        >>> g.addZoneToZone('zone2.2', 'zone3')
 2922        >>> g.zoneHierarchyLevel('zone0')
 2923        0
 2924        >>> g.zoneHierarchyLevel('zone1')
 2925        1
 2926        >>> g.zoneHierarchyLevel('zone2.1')
 2927        2
 2928        >>> g.zoneHierarchyLevel('zone2.2')
 2929        2
 2930        >>> g.zoneHierarchyLevel('zone3')
 2931        3
 2932        >>> sorted(g.decisionsInZone('zone0'))
 2933        [0, 1]
 2934        >>> sorted(g.zoneAncestors('zone0'))
 2935        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 2936        >>> g.subZones('zone1')
 2937        {'zone0'}
 2938        >>> g.zoneParents('zone0')
 2939        {'zone1'}
 2940        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
 2941        >>> g.zoneParents('zone0')
 2942        {'zone1'}
 2943        >>> g.zoneParents('new0')
 2944        {'zone1'}
 2945        >>> sorted(g.zoneAncestors('zone0'))
 2946        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 2947        >>> sorted(g.zoneAncestors('new0'))
 2948        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
 2949        >>> g.decisionsInZone('zone0')
 2950        {1}
 2951        >>> g.decisionsInZone('new0')
 2952        {0}
 2953        >>> sorted(g.subZones('zone1'))
 2954        ['new0', 'zone0']
 2955        >>> g.zoneParents('new0')
 2956        {'zone1'}
 2957        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
 2958        >>> sorted(g.zoneAncestors(0))
 2959        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
 2960        >>> g.subZones('zone1')
 2961        {'zone0'}
 2962        >>> g.subZones('new1')
 2963        {'new0'}
 2964        >>> g.zoneParents('new0')
 2965        {'new1'}
 2966        >>> sorted(g.zoneParents('zone1'))
 2967        ['zone2.1', 'zone2.2']
 2968        >>> sorted(g.zoneParents('new1'))
 2969        ['zone2.1', 'zone2.2']
 2970        >>> g.zoneParents('zone2.1')
 2971        {'zone3'}
 2972        >>> g.zoneParents('zone2.2')
 2973        {'zone3'}
 2974        >>> sorted(g.subZones('zone2.1'))
 2975        ['new1', 'zone1']
 2976        >>> sorted(g.subZones('zone2.2'))
 2977        ['new1', 'zone1']
 2978        >>> sorted(g.allDecisionsInZone('zone2.1'))
 2979        [0, 1]
 2980        >>> sorted(g.allDecisionsInZone('zone2.2'))
 2981        [0, 1]
 2982        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
 2983        >>> g.zoneParents('zone2.1')
 2984        {'zone3'}
 2985        >>> g.zoneParents('zone2.2')
 2986        {'zone3'}
 2987        >>> g.subZones('zone2.1')
 2988        {'zone1'}
 2989        >>> g.subZones('zone2.2')
 2990        {'zone1'}
 2991        >>> g.subZones('new2')
 2992        {'new1'}
 2993        >>> g.zoneParents('new2')
 2994        {'zone3'}
 2995        >>> g.allDecisionsInZone('zone2.1')
 2996        {1}
 2997        >>> g.allDecisionsInZone('zone2.2')
 2998        {1}
 2999        >>> g.allDecisionsInZone('new2')
 3000        {0}
 3001        >>> sorted(g.subZones('zone3'))
 3002        ['new2', 'zone2.1', 'zone2.2']
 3003        >>> g.zoneParents('zone3')
 3004        set()
 3005        >>> sorted(g.allDecisionsInZone('zone3'))
 3006        [0, 1]
 3007        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
 3008        >>> sorted(g.subZones('zone3'))
 3009        ['zone2.1', 'zone2.2']
 3010        >>> g.subZones('new3')
 3011        {'new2'}
 3012        >>> g.zoneParents('zone3')
 3013        set()
 3014        >>> g.zoneParents('new3')
 3015        set()
 3016        >>> g.allDecisionsInZone('zone3')
 3017        {1}
 3018        >>> g.allDecisionsInZone('new3')
 3019        {0}
 3020        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
 3021        >>> g.subZones('new4')
 3022        {'new3'}
 3023        >>> g.zoneHierarchyLevel('new4')
 3024        5
 3025
 3026        Another example of level collapse when trying to replace a zone
 3027        at a level above :
 3028
 3029        >>> g = DecisionGraph()
 3030        >>> g.addDecision('A')
 3031        0
 3032        >>> g.addDecision('B')
 3033        1
 3034        >>> g.createZone('level0', 0)
 3035        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 3036 annotations=[])
 3037        >>> g.createZone('level1', 1)
 3038        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 3039 annotations=[])
 3040        >>> g.createZone('level2', 2)
 3041        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
 3042 annotations=[])
 3043        >>> g.createZone('level3', 3)
 3044        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
 3045 annotations=[])
 3046        >>> g.addDecisionToZone('B', 'level0')
 3047        >>> g.addZoneToZone('level0', 'level1')
 3048        >>> g.addZoneToZone('level1', 'level2')
 3049        >>> g.addZoneToZone('level2', 'level3')
 3050        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
 3051        >>> g.zoneHierarchyLevel('level3')
 3052        3
 3053        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
 3054        >>> g.zoneHierarchyLevel('newFirst')
 3055        1
 3056        >>> g.decisionsInZone('newFirst')
 3057        {0}
 3058        >>> g.decisionsInZone('level3')
 3059        set()
 3060        >>> sorted(g.allDecisionsInZone('level3'))
 3061        [0, 1]
 3062        >>> g.subZones('newFirst')
 3063        set()
 3064        >>> sorted(g.subZones('level3'))
 3065        ['level2', 'newFirst']
 3066        >>> g.zoneParents('newFirst')
 3067        {'level3'}
 3068        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
 3069        >>> g.zoneHierarchyLevel('newSecond')
 3070        2
 3071        >>> g.decisionsInZone('newSecond')
 3072        set()
 3073        >>> g.allDecisionsInZone('newSecond')
 3074        {0}
 3075        >>> g.subZones('newSecond')
 3076        {'newFirst'}
 3077        >>> g.zoneParents('newSecond')
 3078        {'level3'}
 3079        >>> g.zoneParents('newFirst')
 3080        {'newSecond'}
 3081        >>> sorted(g.subZones('level3'))
 3082        ['level2', 'newSecond']
 3083        """
 3084        tID = self.resolveDecision(target)
 3085
 3086        if level < 0:
 3087            raise InvalidLevelError(
 3088                f"Target level must be positive (got {level})."
 3089            )
 3090
 3091        info = self.getZoneInfo(zone)
 3092        if info is None:
 3093            info = self.createZone(zone, level)
 3094        elif level != info.level:
 3095            raise InvalidLevelError(
 3096                f"Target level ({level}) does not match the level of"
 3097                f" the target zone ({zone!r} at level {info.level})."
 3098            )
 3099
 3100        # Collect both parents & ancestors
 3101        parents = self.zoneParents(tID)
 3102        ancestors = set(self.zoneAncestors(tID))
 3103
 3104        # Map from levels to sets of zones from the ancestors pool
 3105        levelMap: Dict[int, Set[base.Zone]] = {}
 3106        highest = -1
 3107        for ancestor in ancestors:
 3108            ancestorLevel = self.zoneHierarchyLevel(ancestor)
 3109            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
 3110            if ancestorLevel > highest:
 3111                highest = ancestorLevel
 3112
 3113        # Figure out if we have target zones to replace or not
 3114        reparentDecision = False
 3115        if level in levelMap:
 3116            # If there are zones at the target level,
 3117            targetZones = levelMap[level]
 3118
 3119            above = set()
 3120            below = set()
 3121
 3122            for replaced in targetZones:
 3123                above |= self.zoneParents(replaced)
 3124                below |= self.subZones(replaced)
 3125                if replaced in parents:
 3126                    reparentDecision = True
 3127
 3128            # Only ancestors should be reparented
 3129            below &= ancestors
 3130
 3131        else:
 3132            # Find levels w/ zones in them above + below
 3133            levelBelow = level - 1
 3134            levelAbove = level + 1
 3135            below = levelMap.get(levelBelow, set())
 3136            above = levelMap.get(levelAbove, set())
 3137
 3138            while len(below) == 0 and levelBelow > 0:
 3139                levelBelow -= 1
 3140                below = levelMap.get(levelBelow, set())
 3141
 3142            if len(below) == 0:
 3143                reparentDecision = True
 3144
 3145            while len(above) == 0 and levelAbove < highest:
 3146                levelAbove += 1
 3147                above = levelMap.get(levelAbove, set())
 3148
 3149        # Handle re-parenting zones below
 3150        for under in below:
 3151            for parent in self.zoneParents(under):
 3152                if parent in ancestors:
 3153                    self.removeZoneFromZone(under, parent)
 3154            self.addZoneToZone(under, zone)
 3155
 3156        # Add this zone to each parent
 3157        for parent in above:
 3158            self.addZoneToZone(zone, parent)
 3159
 3160        # Re-parent the decision itself if necessary
 3161        if reparentDecision:
 3162            # (using set() here to avoid size-change-during-iteration)
 3163            for parent in set(parents):
 3164                self.removeDecisionFromZone(tID, parent)
 3165            self.addDecisionToZone(tID, zone)
 3166
 3167    def getReciprocal(
 3168        self,
 3169        decision: base.AnyDecisionSpecifier,
 3170        transition: base.Transition
 3171    ) -> Optional[base.Transition]:
 3172        """
 3173        Returns the reciprocal edge for the specified transition from the
 3174        specified decision (see `setReciprocal`). Returns
 3175        `None` if no reciprocal has been established for that
 3176        transition, or if that decision or transition does not exist.
 3177        """
 3178        dID = self.resolveDecision(decision)
 3179
 3180        dest = self.getDestination(dID, transition)
 3181        if dest is not None:
 3182            info = cast(
 3183                TransitionProperties,
 3184                self.edges[dID, dest, transition]  # type:ignore
 3185            )
 3186            recip = info.get("reciprocal")
 3187            if recip is not None and not isinstance(recip, base.Transition):
 3188                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
 3189            return recip
 3190        else:
 3191            return None
 3192
 3193    def setReciprocal(
 3194        self,
 3195        decision: base.AnyDecisionSpecifier,
 3196        transition: base.Transition,
 3197        reciprocal: Optional[base.Transition],
 3198        setBoth: bool = True,
 3199        cleanup: bool = True
 3200    ) -> None:
 3201        """
 3202        Sets the 'reciprocal' transition for a particular transition from
 3203        a particular decision, and removes the reciprocal property from
 3204        any old reciprocal transition.
 3205
 3206        Raises a `MissingDecisionError` or a `MissingTransitionError` if
 3207        the specified decision or transition does not exist.
 3208
 3209        Raises an `InvalidDestinationError` if the reciprocal transition
 3210        does not exist, or if it does exist but does not lead back to
 3211        the decision the transition came from.
 3212
 3213        If `setBoth` is True (the default) then the transition which is
 3214        being identified as a reciprocal will also have its reciprocal
 3215        property set, pointing back to the primary transition being
 3216        modified, and any old reciprocal of that transition will have its
 3217        reciprocal set to None. If you want to create a situation with
 3218        non-exclusive reciprocals, use `setBoth=False`.
 3219
 3220        If `cleanup` is True (the default) then abandoned reciprocal
 3221        transitions (for both edges if `setBoth` was true) have their
 3222        reciprocal properties removed. Set `cleanup` to false if you want
 3223        to retain them, although this will result in non-exclusive
 3224        reciprocal relationships.
 3225
 3226        If the `reciprocal` value is None, this deletes the reciprocal
 3227        value entirely, and if `setBoth` is true, it does this for the
 3228        previous reciprocal edge as well. No error is raised in this case
 3229        when there was not already a reciprocal to delete.
 3230
 3231        Note that one should remove a reciprocal relationship before
 3232        redirecting either edge of the pair in a way that gives it a new
 3233        reciprocal, since otherwise, a later attempt to remove the
 3234        reciprocal with `setBoth` set to True (the default) will end up
 3235        deleting the reciprocal information from the other edge that was
 3236        already modified. There is no way to reliably detect and avoid
 3237        this, because two different decisions could (and often do in
 3238        practice) have transitions with identical names, meaning that the
 3239        reciprocal value will still be the same, but it will indicate a
 3240        different edge in virtue of the destination of the edge changing.
 3241
 3242        ## Example
 3243
 3244        >>> g = DecisionGraph()
 3245        >>> g.addDecision('G')
 3246        0
 3247        >>> g.addDecision('H')
 3248        1
 3249        >>> g.addDecision('I')
 3250        2
 3251        >>> g.addTransition('G', 'up', 'H', 'down')
 3252        >>> g.addTransition('G', 'next', 'H', 'prev')
 3253        >>> g.addTransition('H', 'next', 'I', 'prev')
 3254        >>> g.addTransition('H', 'return', 'G')
 3255        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
 3256        Traceback (most recent call last):
 3257        ...
 3258        exploration.core.InvalidDestinationError...
 3259        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
 3260        Traceback (most recent call last):
 3261        ...
 3262        exploration.core.MissingTransitionError...
 3263        >>> g.getReciprocal('G', 'up')
 3264        'down'
 3265        >>> g.getReciprocal('H', 'down')
 3266        'up'
 3267        >>> g.getReciprocal('H', 'return') is None
 3268        True
 3269        >>> g.setReciprocal('G', 'up', 'return')
 3270        >>> g.getReciprocal('G', 'up')
 3271        'return'
 3272        >>> g.getReciprocal('H', 'down') is None
 3273        True
 3274        >>> g.getReciprocal('H', 'return')
 3275        'up'
 3276        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
 3277        >>> g.getReciprocal('G', 'up') is None
 3278        True
 3279        >>> g.getReciprocal('H', 'down') is None
 3280        True
 3281        >>> g.getReciprocal('H', 'return') is None
 3282        True
 3283        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
 3284        >>> g.getReciprocal('G', 'up')
 3285        'down'
 3286        >>> g.getReciprocal('H', 'down') is None
 3287        True
 3288        >>> g.getReciprocal('H', 'return') is None
 3289        True
 3290        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
 3291        >>> g.getReciprocal('G', 'up')
 3292        'down'
 3293        >>> g.getReciprocal('H', 'down') is None
 3294        True
 3295        >>> g.getReciprocal('H', 'return')
 3296        'up'
 3297        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
 3298        >>> g.getReciprocal('G', 'up')
 3299        'down'
 3300        >>> g.getReciprocal('H', 'down')
 3301        'up'
 3302        >>> g.getReciprocal('H', 'return') # unchanged
 3303        'up'
 3304        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
 3305        >>> g.getReciprocal('G', 'up')
 3306        'return'
 3307        >>> g.getReciprocal('H', 'down')
 3308        'up'
 3309        >>> g.getReciprocal('H', 'return') # unchanged
 3310        'up'
 3311        >>> # Cleanup only applies to reciprocal if setBoth is true
 3312        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
 3313        >>> g.getReciprocal('G', 'up')
 3314        'return'
 3315        >>> g.getReciprocal('H', 'down')
 3316        'up'
 3317        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
 3318        'up'
 3319        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
 3320        >>> g.getReciprocal('G', 'up')
 3321        'down'
 3322        >>> g.getReciprocal('H', 'down')
 3323        'up'
 3324        >>> g.getReciprocal('H', 'return') is None # cleaned up
 3325        True
 3326        """
 3327        dID = self.resolveDecision(decision)
 3328
 3329        dest = self.destination(dID, transition) # possible KeyError
 3330        if reciprocal is None:
 3331            rDest = None
 3332        else:
 3333            rDest = self.getDestination(dest, reciprocal)
 3334
 3335        # Set or delete reciprocal property
 3336        if reciprocal is None:
 3337            # Delete the property
 3338            info = self.edges[dID, dest, transition]  # type:ignore
 3339
 3340            old = info.pop('reciprocal')
 3341            if setBoth:
 3342                rDest = self.getDestination(dest, old)
 3343                if rDest != dID:
 3344                    raise RuntimeError(
 3345                        f"Invalid reciprocal {old!r} for transition"
 3346                        f" {transition!r} from {self.identityOf(dID)}:"
 3347                        f" destination is {rDest}."
 3348                    )
 3349                rInfo = self.edges[dest, dID, old]  # type:ignore
 3350                if 'reciprocal' in rInfo:
 3351                    del rInfo['reciprocal']
 3352        else:
 3353            # Set the property, checking for errors first
 3354            if rDest is None:
 3355                raise MissingTransitionError(
 3356                    f"Reciprocal transition {reciprocal!r} for"
 3357                    f" transition {transition!r} from decision"
 3358                    f" {self.identityOf(dID)} does not exist at"
 3359                    f" decision {self.identityOf(dest)}"
 3360                )
 3361
 3362            if rDest != dID:
 3363                raise InvalidDestinationError(
 3364                    f"Reciprocal transition {reciprocal!r} from"
 3365                    f" decision {self.identityOf(dest)} does not lead"
 3366                    f" back to decision {self.identityOf(dID)}."
 3367                )
 3368
 3369            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
 3370            abandoned = eProps.get('reciprocal')
 3371            eProps['reciprocal'] = reciprocal
 3372            if cleanup and abandoned not in (None, reciprocal):
 3373                aProps = self.edges[dest, dID, abandoned]  # type:ignore
 3374                if 'reciprocal' in aProps:
 3375                    del aProps['reciprocal']
 3376
 3377            if setBoth:
 3378                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
 3379                revAbandoned = rProps.get('reciprocal')
 3380                rProps['reciprocal'] = transition
 3381                # Sever old reciprocal relationship
 3382                if cleanup and revAbandoned not in (None, transition):
 3383                    raProps = self.edges[
 3384                        dID,  # type:ignore
 3385                        dest,
 3386                        revAbandoned
 3387                    ]
 3388                    del raProps['reciprocal']
 3389
 3390    def getReciprocalPair(
 3391        self,
 3392        decision: base.AnyDecisionSpecifier,
 3393        transition: base.Transition
 3394    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
 3395        """
 3396        Returns a tuple containing both the destination decision ID and
 3397        the transition at that decision which is the reciprocal of the
 3398        specified destination & transition. Returns `None` if no
 3399        reciprocal has been established for that transition, or if that
 3400        decision or transition does not exist.
 3401
 3402        >>> g = DecisionGraph()
 3403        >>> g.addDecision('A')
 3404        0
 3405        >>> g.addDecision('B')
 3406        1
 3407        >>> g.addDecision('C')
 3408        2
 3409        >>> g.addTransition('A', 'up', 'B', 'down')
 3410        >>> g.addTransition('B', 'right', 'C', 'left')
 3411        >>> g.addTransition('A', 'oneway', 'C')
 3412        >>> g.getReciprocalPair('A', 'up')
 3413        (1, 'down')
 3414        >>> g.getReciprocalPair('B', 'down')
 3415        (0, 'up')
 3416        >>> g.getReciprocalPair('B', 'right')
 3417        (2, 'left')
 3418        >>> g.getReciprocalPair('C', 'left')
 3419        (1, 'right')
 3420        >>> g.getReciprocalPair('C', 'up') is None
 3421        True
 3422        >>> g.getReciprocalPair('Q', 'up') is None
 3423        True
 3424        >>> g.getReciprocalPair('A', 'tunnel') is None
 3425        True
 3426        """
 3427        try:
 3428            dID = self.resolveDecision(decision)
 3429        except MissingDecisionError:
 3430            return None
 3431
 3432        reciprocal = self.getReciprocal(dID, transition)
 3433        if reciprocal is None:
 3434            return None
 3435        else:
 3436            destination = self.getDestination(dID, transition)
 3437            if destination is None:
 3438                return None
 3439            else:
 3440                return (destination, reciprocal)
 3441
 3442    def addDecision(
 3443        self,
 3444        name: base.DecisionName,
 3445        domain: Optional[base.Domain] = None,
 3446        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3447        annotations: Optional[List[base.Annotation]] = None
 3448    ) -> base.DecisionID:
 3449        """
 3450        Adds a decision to the graph, without any transitions yet. Each
 3451        decision will be assigned an ID so name collisions are allowed,
 3452        but it's usually best to keep names unique at least within each
 3453        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
 3454        used for the decision's domain. A dictionary of tags and/or a
 3455        list of annotations (strings in both cases) may be provided.
 3456
 3457        Returns the newly-assigned `DecisionID` for the decision it
 3458        created.
 3459
 3460        Emits a `DecisionCollisionWarning` if a decision with the
 3461        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
 3462        global variable is set to `True`.
 3463        """
 3464        # Defaults
 3465        if domain is None:
 3466            domain = base.DEFAULT_DOMAIN
 3467        if tags is None:
 3468            tags = {}
 3469        if annotations is None:
 3470            annotations = []
 3471
 3472        # Error checking
 3473        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 3474            warnings.warn(
 3475                (
 3476                    f"Adding decision {name!r}: Another decision with"
 3477                    f" that name already exists."
 3478                ),
 3479                DecisionCollisionWarning
 3480            )
 3481
 3482        dID = self._assignID()
 3483
 3484        # Add the decision
 3485        self.add_node(
 3486            dID,
 3487            name=name,
 3488            domain=domain,
 3489            tags=tags,
 3490            annotations=annotations
 3491        )
 3492        #TODO: Elide tags/annotations if they're empty?
 3493
 3494        # Track it in our `nameLookup` dictionary
 3495        self.nameLookup.setdefault(name, []).append(dID)
 3496
 3497        return dID
 3498
 3499    def addIdentifiedDecision(
 3500        self,
 3501        dID: base.DecisionID,
 3502        name: base.DecisionName,
 3503        domain: Optional[base.Domain] = None,
 3504        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3505        annotations: Optional[List[base.Annotation]] = None
 3506    ) -> None:
 3507        """
 3508        Adds a new decision to the graph using a specific decision ID,
 3509        rather than automatically assigning a new decision ID like
 3510        `addDecision` does. Otherwise works like `addDecision`.
 3511
 3512        Raises a `MechanismCollisionError` if the specified decision ID
 3513        is already in use.
 3514        """
 3515        # Defaults
 3516        if domain is None:
 3517            domain = base.DEFAULT_DOMAIN
 3518        if tags is None:
 3519            tags = {}
 3520        if annotations is None:
 3521            annotations = []
 3522
 3523        # Error checking
 3524        if dID in self.nodes:
 3525            raise MechanismCollisionError(
 3526                f"Cannot add a node with id {dID} and name {name!r}:"
 3527                f" that ID is already used by node {self.identityOf(dID)}"
 3528            )
 3529
 3530        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 3531            warnings.warn(
 3532                (
 3533                    f"Adding decision {name!r}: Another decision with"
 3534                    f" that name already exists."
 3535                ),
 3536                DecisionCollisionWarning
 3537            )
 3538
 3539        # Add the decision
 3540        self.add_node(
 3541            dID,
 3542            name=name,
 3543            domain=domain,
 3544            tags=tags,
 3545            annotations=annotations
 3546        )
 3547        #TODO: Elide tags/annotations if they're empty?
 3548
 3549        # Track it in our `nameLookup` dictionary
 3550        self.nameLookup.setdefault(name, []).append(dID)
 3551
 3552    def addTransition(
 3553        self,
 3554        fromDecision: base.AnyDecisionSpecifier,
 3555        name: base.Transition,
 3556        toDecision: base.AnyDecisionSpecifier,
 3557        reciprocal: Optional[base.Transition] = None,
 3558        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3559        annotations: Optional[List[base.Annotation]] = None,
 3560        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 3561        revAnnotations: Optional[List[base.Annotation]] = None,
 3562        requires: Optional[base.Requirement] = None,
 3563        consequence: Optional[base.Consequence] = None,
 3564        revRequires: Optional[base.Requirement] = None,
 3565        revConsequece: Optional[base.Consequence] = None
 3566    ) -> None:
 3567        """
 3568        Adds a transition connecting two decisions. A specifier for each
 3569        decision is required, as is a name for the transition. If a
 3570        `reciprocal` is provided, a reciprocal edge will be added in the
 3571        opposite direction using that name; by default only the specified
 3572        edge is added. A `TransitionCollisionError` will be raised if the
 3573        `reciprocal` matches the name of an existing edge at the
 3574        destination decision.
 3575
 3576        Both decisions must already exist, or a `MissingDecisionError`
 3577        will be raised.
 3578
 3579        A dictionary of tags and/or a list of annotations may be
 3580        provided. Tags and/or annotations for the reverse edge may also
 3581        be specified if one is being added.
 3582
 3583        The `requires`, `consequence`, `revRequires`, and `revConsequece`
 3584        arguments specify requirements and/or consequences of the new
 3585        outgoing and reciprocal edges.
 3586        """
 3587        # Defaults
 3588        if tags is None:
 3589            tags = {}
 3590        if annotations is None:
 3591            annotations = []
 3592        if revTags is None:
 3593            revTags = {}
 3594        if revAnnotations is None:
 3595            revAnnotations = []
 3596
 3597        # Error checking
 3598        fromID = self.resolveDecision(fromDecision)
 3599        toID = self.resolveDecision(toDecision)
 3600
 3601        # Note: have to check this first so we don't add the forward edge
 3602        # and then error out after a side effect!
 3603        if (
 3604            reciprocal is not None
 3605        and self.getDestination(toDecision, reciprocal) is not None
 3606        ):
 3607            raise TransitionCollisionError(
 3608                f"Cannot add a transition from"
 3609                f" {self.identityOf(fromDecision)} to"
 3610                f" {self.identityOf(toDecision)} with reciprocal edge"
 3611                f" {reciprocal!r}: {reciprocal!r} is already used as an"
 3612                f" edge name at {self.identityOf(toDecision)}."
 3613            )
 3614
 3615        # Add the edge
 3616        self.add_edge(
 3617            fromID,
 3618            toID,
 3619            key=name,
 3620            tags=tags,
 3621            annotations=annotations
 3622        )
 3623        self.setTransitionRequirement(fromDecision, name, requires)
 3624        if consequence is not None:
 3625            self.setConsequence(fromDecision, name, consequence)
 3626        if reciprocal is not None:
 3627            # Add the reciprocal edge
 3628            self.add_edge(
 3629                toID,
 3630                fromID,
 3631                key=reciprocal,
 3632                tags=revTags,
 3633                annotations=revAnnotations
 3634            )
 3635            self.setReciprocal(fromID, name, reciprocal)
 3636            self.setTransitionRequirement(
 3637                toDecision,
 3638                reciprocal,
 3639                revRequires
 3640            )
 3641            if revConsequece is not None:
 3642                self.setConsequence(toDecision, reciprocal, revConsequece)
 3643
 3644    def removeTransition(
 3645        self,
 3646        fromDecision: base.AnyDecisionSpecifier,
 3647        transition: base.Transition,
 3648        removeReciprocal=False
 3649    ) -> Union[
 3650        TransitionProperties,
 3651        Tuple[TransitionProperties, TransitionProperties]
 3652    ]:
 3653        """
 3654        Removes a transition. If `removeReciprocal` is true (False is the
 3655        default) any reciprocal transition will also be removed (but no
 3656        error will occur if there wasn't a reciprocal).
 3657
 3658        For each removed transition, *every* transition that targeted
 3659        that transition as its reciprocal will have its reciprocal set to
 3660        `None`, to avoid leaving any invalid reciprocal values.
 3661
 3662        Raises a `KeyError` if either the target decision or the target
 3663        transition does not exist.
 3664
 3665        Returns a transition properties dictionary with the properties
 3666        of the removed transition, or if `removeReciprocal` is true,
 3667        returns a pair of such dictionaries for the target transition
 3668        and its reciprocal.
 3669
 3670        ## Example
 3671
 3672        >>> g = DecisionGraph()
 3673        >>> g.addDecision('A')
 3674        0
 3675        >>> g.addDecision('B')
 3676        1
 3677        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
 3678        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
 3679        >>> g.addTransition('A', 'next', 'B')
 3680        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
 3681        >>> p = g.removeTransition('A', 'up')
 3682        >>> p['tags']
 3683        {'wide'}
 3684        >>> g.destinationsFrom('A')
 3685        {'in': 1, 'next': 1}
 3686        >>> g.destinationsFrom('B')
 3687        {'down': 0, 'out': 0}
 3688        >>> g.getReciprocal('B', 'down') is None
 3689        True
 3690        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
 3691        'down'
 3692        >>> g.getReciprocal('A', 'in') # not affected
 3693        'out'
 3694        >>> g.getReciprocal('B', 'out') # not affected
 3695        'in'
 3696        >>> # Now with removeReciprocal set to True
 3697        >>> g.addTransition('A', 'up', 'B') # add this back in
 3698        >>> g.setReciprocal('A', 'up', 'down') # sets both
 3699        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
 3700        >>> g.destinationsFrom('A')
 3701        {'in': 1, 'next': 1}
 3702        >>> g.destinationsFrom('B')
 3703        {'out': 0}
 3704        >>> g.getReciprocal('A', 'next') is None
 3705        True
 3706        >>> g.getReciprocal('A', 'in') # not affected
 3707        'out'
 3708        >>> g.getReciprocal('B', 'out') # not affected
 3709        'in'
 3710        >>> g.removeTransition('A', 'none')
 3711        Traceback (most recent call last):
 3712        ...
 3713        exploration.core.MissingTransitionError...
 3714        >>> g.removeTransition('Z', 'nope')
 3715        Traceback (most recent call last):
 3716        ...
 3717        exploration.core.MissingDecisionError...
 3718        """
 3719        # Resolve target ID
 3720        fromID = self.resolveDecision(fromDecision)
 3721
 3722        # raises if either is missing:
 3723        destination = self.destination(fromID, transition)
 3724        reciprocal = self.getReciprocal(fromID, transition)
 3725
 3726        # Get dictionaries of parallel & antiparallel edges to be
 3727        # checked for invalid reciprocals after removing edges
 3728        # Note: these will update live as we remove edges
 3729        allAntiparallel = self[destination][fromID]
 3730        allParallel = self[fromID][destination]
 3731
 3732        # Remove the target edge
 3733        fProps = self.getTransitionProperties(fromID, transition)
 3734        self.remove_edge(fromID, destination, transition)
 3735
 3736        # Clean up any dangling reciprocal values
 3737        for tProps in allAntiparallel.values():
 3738            if tProps.get('reciprocal') == transition:
 3739                del tProps['reciprocal']
 3740
 3741        # Remove the reciprocal if requested
 3742        if removeReciprocal and reciprocal is not None:
 3743            rProps = self.getTransitionProperties(destination, reciprocal)
 3744            self.remove_edge(destination, fromID, reciprocal)
 3745
 3746            # Clean up any dangling reciprocal values
 3747            for tProps in allParallel.values():
 3748                if tProps.get('reciprocal') == reciprocal:
 3749                    del tProps['reciprocal']
 3750
 3751            return (fProps, rProps)
 3752        else:
 3753            return fProps
 3754
 3755    def addMechanism(
 3756        self,
 3757        name: base.MechanismName,
 3758        where: Optional[base.AnyDecisionSpecifier] = None
 3759    ) -> base.MechanismID:
 3760        """
 3761        Creates a new mechanism with the given name at the specified
 3762        decision, returning its assigned ID. If `where` is `None`, it
 3763        creates a global mechanism. Raises a `MechanismCollisionError`
 3764        if a mechanism with the same name already exists at a specified
 3765        decision (or already exists as a global mechanism).
 3766
 3767        Note that if the decision is deleted, the mechanism will be as
 3768        well.
 3769
 3770        Since `MechanismState`s are not tracked by `DecisionGraph`s but
 3771        instead are part of a `State`, the mechanism won't be in any
 3772        particular state, which means it will be treated as being in the
 3773        `base.DEFAULT_MECHANISM_STATE`.
 3774        """
 3775        if where is None:
 3776            mechs = self.globalMechanisms
 3777            dID = None
 3778        else:
 3779            dID = self.resolveDecision(where)
 3780            mechs = self.nodes[dID].setdefault('mechanisms', {})
 3781
 3782        if name in mechs:
 3783            if dID is None:
 3784                raise MechanismCollisionError(
 3785                    f"A global mechanism named {name!r} already exists."
 3786                )
 3787            else:
 3788                raise MechanismCollisionError(
 3789                    f"A mechanism named {name!r} already exists at"
 3790                    f" decision {self.identityOf(dID)}."
 3791                )
 3792
 3793        mID = self._assignMechanismID()
 3794        mechs[name] = mID
 3795        self.mechanisms[mID] = (dID, name)
 3796        return mID
 3797
 3798    def mechanismsAt(
 3799        self,
 3800        decision: base.AnyDecisionSpecifier
 3801    ) -> Dict[base.MechanismName, base.MechanismID]:
 3802        """
 3803        Returns a dictionary mapping mechanism names to their IDs for
 3804        all mechanisms at the specified decision.
 3805        """
 3806        dID = self.resolveDecision(decision)
 3807
 3808        return self.nodes[dID]['mechanisms']
 3809
 3810    def mechanismDetails(
 3811        self,
 3812        mID: base.MechanismID
 3813    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
 3814        """
 3815        Returns a tuple containing the decision ID and mechanism name
 3816        for the specified mechanism. Returns `None` if there is no
 3817        mechanism with that ID. For global mechanisms, `None` is used in
 3818        place of a decision ID.
 3819        """
 3820        return self.mechanisms.get(mID)
 3821
 3822    def deleteMechanism(self, mID: base.MechanismID) -> None:
 3823        """
 3824        Deletes the specified mechanism.
 3825        """
 3826        name, dID = self.mechanisms.pop(mID)
 3827
 3828        del self.nodes[dID]['mechanisms'][name]
 3829
 3830    def localLookup(
 3831        self,
 3832        startFrom: Union[
 3833            base.AnyDecisionSpecifier,
 3834            Collection[base.AnyDecisionSpecifier]
 3835        ],
 3836        findAmong: Callable[
 3837            ['DecisionGraph', Union[Set[base.DecisionID], str]],
 3838            Optional[LookupResult]
 3839        ],
 3840        fallbackLayerName: Optional[str] = "fallback",
 3841        fallbackToAllDecisions: bool = True
 3842    ) -> Optional[LookupResult]:
 3843        """
 3844        Looks up some kind of result in the graph by starting from a
 3845        base set of decisions and widening the search iteratively based
 3846        on zones. This first searches for result(s) in the set of
 3847        decisions given, then in the set of all decisions which are in
 3848        level-0 zones containing those decisions, then in level-1 zones,
 3849        etc. When it runs out of relevant zones, it will check all
 3850        decisions which are in any domain that a decision from the
 3851        initial search set is in, and then if `fallbackLayerName` is a
 3852        string, it will provide that string instead of a set of decision
 3853        IDs to the `findAmong` function as the next layer to search.
 3854        After the `fallbackLayerName` is used, if
 3855        `fallbackToAllDecisions` is `True` (the default) a final search
 3856        will be run on all decisions in the graph. The provided
 3857        `findAmong` function is called on each successive decision ID
 3858        set, until it generates a non-`None` result. We stop and return
 3859        that non-`None` result as soon as one is generated. But if none
 3860        of the decision sets consulted generate non-`None` results, then
 3861        the entire result will be `None`.
 3862        """
 3863        # Normalize starting decisions to a set
 3864        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
 3865            startFrom = set([startFrom])
 3866
 3867        # Resolve decision IDs; convert to list
 3868        searchArea: Union[Set[base.DecisionID], str] = set(
 3869            self.resolveDecision(spec) for spec in startFrom
 3870        )
 3871
 3872        # Find all ancestor zones & all relevant domains
 3873        allAncestors = set()
 3874        relevantDomains = set()
 3875        for startingDecision in searchArea:
 3876            allAncestors |= self.zoneAncestors(startingDecision)
 3877            relevantDomains.add(self.domainFor(startingDecision))
 3878
 3879        # Build layers dictionary
 3880        ancestorLayers: Dict[int, Set[base.Zone]] = {}
 3881        for zone in allAncestors:
 3882            info = self.getZoneInfo(zone)
 3883            assert info is not None
 3884            level = info.level
 3885            ancestorLayers.setdefault(level, set()).add(zone)
 3886
 3887        searchLayers: LookupLayersList = (
 3888            cast(LookupLayersList, [None])
 3889          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
 3890          + cast(LookupLayersList, ["domains"])
 3891        )
 3892        if fallbackLayerName is not None:
 3893            searchLayers.append("fallback")
 3894
 3895        if fallbackToAllDecisions:
 3896            searchLayers.append("all")
 3897
 3898        # Continue our search through zone layers
 3899        for layer in searchLayers:
 3900            # Update search area on subsequent iterations
 3901            if layer == "domains":
 3902                searchArea = set()
 3903                for relevant in relevantDomains:
 3904                    searchArea |= self.allDecisionsInDomain(relevant)
 3905            elif layer == "fallback":
 3906                assert fallbackLayerName is not None
 3907                searchArea = fallbackLayerName
 3908            elif layer == "all":
 3909                searchArea = set(self.nodes)
 3910            elif layer is not None:
 3911                layer = cast(int, layer)  # must be an integer
 3912                searchZones = ancestorLayers[layer]
 3913                searchArea = set()
 3914                for zone in searchZones:
 3915                    searchArea |= self.allDecisionsInZone(zone)
 3916            # else it's the first iteration and we use the starting
 3917            # searchArea
 3918
 3919            searchResult: Optional[LookupResult] = findAmong(
 3920                self,
 3921                searchArea
 3922            )
 3923
 3924            if searchResult is not None:
 3925                return searchResult
 3926
 3927        # Didn't find any non-None results.
 3928        return None
 3929
 3930    @staticmethod
 3931    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
 3932        ['DecisionGraph', Union[Set[base.DecisionID], str]],
 3933        Optional[base.MechanismID]
 3934    ]:
 3935        """
 3936        Returns a search function that looks for the given mechanism ID,
 3937        suitable for use with `localLookup`. The finder will raise a
 3938        `MechanismCollisionError` if it finds more than one mechanism
 3939        with the specified name at the same level of the search.
 3940        """
 3941        def namedMechanismFinder(
 3942            graph: 'DecisionGraph',
 3943            searchIn: Union[Set[base.DecisionID], str]
 3944        ) -> Optional[base.MechanismID]:
 3945            """
 3946            Generated finder function for `localLookup` to find a unique
 3947            mechanism by name.
 3948            """
 3949            candidates: List[base.DecisionID] = []
 3950
 3951            if searchIn == "fallback":
 3952                if name in graph.globalMechanisms:
 3953                    candidates = [graph.globalMechanisms[name]]
 3954
 3955            else:
 3956                assert isinstance(searchIn, set)
 3957                for dID in searchIn:
 3958                    mechs = graph.nodes[dID].get('mechanisms', {})
 3959                    if name in mechs:
 3960                        candidates.append(mechs[name])
 3961
 3962            if len(candidates) > 1:
 3963                raise MechanismCollisionError(
 3964                    f"There are {len(candidates)} mechanisms named {name!r}"
 3965                    f" in the search area ({len(searchIn)} decisions(s))."
 3966                )
 3967            elif len(candidates) == 1:
 3968                return candidates[0]
 3969            else:
 3970                return None
 3971
 3972        return namedMechanismFinder
 3973
 3974    def lookupMechanism(
 3975        self,
 3976        startFrom: Union[
 3977            base.AnyDecisionSpecifier,
 3978            Collection[base.AnyDecisionSpecifier]
 3979        ],
 3980        name: base.MechanismName
 3981    ) -> base.MechanismID:
 3982        """
 3983        Looks up the mechanism with the given name 'closest' to the
 3984        given decision or set of decisions. First it looks for a
 3985        mechanism with that name that's at one of those decisions. Then
 3986        it starts looking in level-0 zones which contain any of them,
 3987        then in level-1 zones, and so on. If it finds two mechanisms
 3988        with the target name during the same search pass, it raises a
 3989        `MechanismCollisionError`, but if it finds one it returns it.
 3990        Raises a `MissingMechanismError` if there is no mechanisms with
 3991        that name among global mechanisms (searched after the last
 3992        applicable level of zones) or anywhere in the graph (which is the
 3993        final level of search after checking global mechanisms).
 3994
 3995        For example:
 3996
 3997        >>> d = DecisionGraph()
 3998        >>> d.addDecision('A')
 3999        0
 4000        >>> d.addDecision('B')
 4001        1
 4002        >>> d.addDecision('C')
 4003        2
 4004        >>> d.addDecision('D')
 4005        3
 4006        >>> d.addDecision('E')
 4007        4
 4008        >>> d.addMechanism('switch', 'A')
 4009        0
 4010        >>> d.addMechanism('switch', 'B')
 4011        1
 4012        >>> d.addMechanism('switch', 'C')
 4013        2
 4014        >>> d.addMechanism('lever', 'D')
 4015        3
 4016        >>> d.addMechanism('lever', None)  # global
 4017        4
 4018        >>> d.createZone('Z1', 0)
 4019        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 4020 annotations=[])
 4021        >>> d.createZone('Z2', 0)
 4022        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
 4023 annotations=[])
 4024        >>> d.createZone('Zup', 1)
 4025        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
 4026 annotations=[])
 4027        >>> d.addDecisionToZone('A', 'Z1')
 4028        >>> d.addDecisionToZone('B', 'Z1')
 4029        >>> d.addDecisionToZone('C', 'Z2')
 4030        >>> d.addDecisionToZone('D', 'Z2')
 4031        >>> d.addDecisionToZone('E', 'Z1')
 4032        >>> d.addZoneToZone('Z1', 'Zup')
 4033        >>> d.addZoneToZone('Z2', 'Zup')
 4034        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
 4035        Traceback (most recent call last):
 4036        ...
 4037        exploration.core.MechanismCollisionError...
 4038        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
 4039        4
 4040        >>> d.lookupMechanism({'D'}, 'lever')  # local
 4041        3
 4042        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
 4043        3
 4044        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
 4045        3
 4046        >>> d.lookupMechanism({'A'}, 'switch')  # local
 4047        0
 4048        >>> d.lookupMechanism({'B'}, 'switch')  # local
 4049        1
 4050        >>> d.lookupMechanism({'C'}, 'switch')  # local
 4051        2
 4052        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
 4053        Traceback (most recent call last):
 4054        ...
 4055        exploration.core.MechanismCollisionError...
 4056        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
 4057        Traceback (most recent call last):
 4058        ...
 4059        exploration.core.MechanismCollisionError...
 4060        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
 4061        1
 4062        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
 4063        Traceback (most recent call last):
 4064        ...
 4065        exploration.core.MechanismCollisionError...
 4066        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
 4067        Traceback (most recent call last):
 4068        ...
 4069        exploration.core.MechanismCollisionError...
 4070        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
 4071        2
 4072        """
 4073        result = self.localLookup(
 4074            startFrom,
 4075            DecisionGraph.uniqueMechanismFinder(name)
 4076        )
 4077        if result is None:
 4078            raise MissingMechanismError(
 4079                f"No mechanism named {name!r}"
 4080            )
 4081        else:
 4082            return result
 4083
 4084    def resolveMechanism(
 4085        self,
 4086        specifier: base.AnyMechanismSpecifier,
 4087        startFrom: Union[
 4088            None,
 4089            base.AnyDecisionSpecifier,
 4090            Collection[base.AnyDecisionSpecifier]
 4091        ] = None
 4092    ) -> base.MechanismID:
 4093        """
 4094        Works like `lookupMechanism`, except it accepts a
 4095        `base.AnyMechanismSpecifier` which may have position information
 4096        baked in, and so the `startFrom` information is optional. If
 4097        position information isn't specified in the mechanism specifier
 4098        and startFrom is not provided, the mechanism is searched for at
 4099        the global scope and then in the entire graph. On the other
 4100        hand, if the specifier includes any position information, the
 4101        startFrom value provided here will be ignored.
 4102        """
 4103        if isinstance(specifier, base.MechanismID):
 4104            return specifier
 4105
 4106        elif isinstance(specifier, base.MechanismName):
 4107            if startFrom is None:
 4108                startFrom = set()
 4109            return self.lookupMechanism(startFrom, specifier)
 4110
 4111        elif isinstance(specifier, tuple) and len(specifier) == 4:
 4112            domain, zone, decision, mechanism = specifier
 4113            if domain is None and zone is None and decision is None:
 4114                if startFrom is None:
 4115                    startFrom = set()
 4116                return self.lookupMechanism(startFrom, mechanism)
 4117
 4118            elif decision is not None:
 4119                startFrom = {
 4120                    self.resolveDecision(
 4121                        base.DecisionSpecifier(domain, zone, decision)
 4122                    )
 4123                }
 4124                return self.lookupMechanism(startFrom, mechanism)
 4125
 4126            else:  # decision is None but domain and/or zone aren't
 4127                startFrom = set()
 4128                if zone is not None:
 4129                    baseStart = self.allDecisionsInZone(zone)
 4130                else:
 4131                    baseStart = set(self)
 4132
 4133                if domain is None:
 4134                    startFrom = baseStart
 4135                else:
 4136                    for dID in baseStart:
 4137                        if self.domainFor(dID) == domain:
 4138                            startFrom.add(dID)
 4139                return self.lookupMechanism(startFrom, mechanism)
 4140
 4141        else:
 4142            raise TypeError(
 4143                f"Invalid mechanism specifier: {repr(specifier)}"
 4144                f"\n(Must be a mechanism ID, mechanism name, or"
 4145                f" mechanism specifier tuple)"
 4146            )
 4147
 4148    def walkConsequenceMechanisms(
 4149        self,
 4150        consequence: base.Consequence,
 4151        searchFrom: Set[base.DecisionID]
 4152    ) -> Generator[base.MechanismID, None, None]:
 4153        """
 4154        Yields each requirement in the given `base.Consequence`,
 4155        including those in `base.Condition`s, `base.ConditionalSkill`s
 4156        within `base.Challenge`s, and those set or toggled by
 4157        `base.Effect`s. The `searchFrom` argument specifies where to
 4158        start searching for mechanisms, since requirements include them
 4159        by name, not by ID.
 4160        """
 4161        for part in base.walkParts(consequence):
 4162            if isinstance(part, dict):
 4163                if 'skills' in part:  # a Challenge
 4164                    for cSkill in part['skills'].walk():
 4165                        if isinstance(cSkill, base.ConditionalSkill):
 4166                            yield from self.walkRequirementMechanisms(
 4167                                cSkill.requirement,
 4168                                searchFrom
 4169                            )
 4170                elif 'condition' in part:  # a Condition
 4171                    yield from self.walkRequirementMechanisms(
 4172                        part['condition'],
 4173                        searchFrom
 4174                    )
 4175                elif 'value' in part:  # an Effect
 4176                    val = part['value']
 4177                    if part['type'] == 'set':
 4178                        if (
 4179                            isinstance(val, tuple)
 4180                        and len(val) == 2
 4181                        and isinstance(val[1], base.State)
 4182                        ):
 4183                            yield from self.walkRequirementMechanisms(
 4184                                base.ReqMechanism(val[0], val[1]),
 4185                                searchFrom
 4186                            )
 4187                    elif part['type'] == 'toggle':
 4188                        if isinstance(val, tuple):
 4189                            assert len(val) == 2
 4190                            yield from self.walkRequirementMechanisms(
 4191                                base.ReqMechanism(val[0], '_'),
 4192                                  # state part is ignored here
 4193                                searchFrom
 4194                            )
 4195
 4196    def walkRequirementMechanisms(
 4197        self,
 4198        req: base.Requirement,
 4199        searchFrom: Set[base.DecisionID]
 4200    ) -> Generator[base.MechanismID, None, None]:
 4201        """
 4202        Given a requirement, yields any mechanisms mentioned in that
 4203        requirement, in depth-first traversal order.
 4204        """
 4205        for part in req.walk():
 4206            if isinstance(part, base.ReqMechanism):
 4207                mech = part.mechanism
 4208                yield self.resolveMechanism(
 4209                    mech,
 4210                    startFrom=searchFrom
 4211                )
 4212
 4213    def addUnexploredEdge(
 4214        self,
 4215        fromDecision: base.AnyDecisionSpecifier,
 4216        name: base.Transition,
 4217        destinationName: Optional[base.DecisionName] = None,
 4218        reciprocal: Optional[base.Transition] = 'return',
 4219        toDomain: Optional[base.Domain] = None,
 4220        placeInZone: Union[base.Zone, type[base.DefaultZone], None] = None,
 4221        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 4222        annotations: Optional[List[base.Annotation]] = None,
 4223        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 4224        revAnnotations: Optional[List[base.Annotation]] = None,
 4225        requires: Optional[base.Requirement] = None,
 4226        consequence: Optional[base.Consequence] = None,
 4227        revRequires: Optional[base.Requirement] = None,
 4228        revConsequece: Optional[base.Consequence] = None
 4229    ) -> base.DecisionID:
 4230        """
 4231        Adds a transition connecting to a new decision named `'_u.-n-'`
 4232        where '-n-' is the number of unknown decisions (named or not)
 4233        that have ever been created in this graph (or using the
 4234        specified destination name if one is provided). This represents
 4235        a transition to an unknown destination. The destination node
 4236        gets tagged 'unconfirmed'.
 4237
 4238        This also adds a reciprocal transition in the reverse direction,
 4239        unless `reciprocal` is set to `None`. The reciprocal will use
 4240        the provided name (default is 'return'). The new decision will
 4241        be in the same domain as the decision it's connected to, unless
 4242        `toDecision` is specified, in which case it will be in that
 4243        domain.
 4244
 4245        The new decision will not be placed into any zones, unless
 4246        `placeInZone` is specified, in which case it will be placed into
 4247        that zone. If that zone needs to be created, it will be created
 4248        at level 0; in that case that zone will be added to any
 4249        grandparent zones of the decision we're branching off of. If
 4250        `placeInZone` is set to `base.DefaultZone`, then the new
 4251        decision will be placed into each parent zone of the decision
 4252        we're branching off of, as long as the new decision is in the
 4253        same domain as the decision we're branching from (otherwise only
 4254        an explicit `placeInZone` would apply).
 4255
 4256        The ID of the decision that was created is returned.
 4257
 4258        A `MissingDecisionError` will be raised if the starting decision
 4259        does not exist, a `TransitionCollisionError` will be raised if
 4260        it exists but already has a transition with the given name, and a
 4261        `DecisionCollisionWarning` will be issued if a decision with the
 4262        specified destination name already exists (won't happen when
 4263        using an automatic name).
 4264
 4265        Lists of tags and/or annotations (strings in both cases) may be
 4266        provided. These may also be provided for the reciprocal edge.
 4267
 4268        Similarly, requirements and/or consequences for either edge may
 4269        be provided.
 4270
 4271        ## Example
 4272
 4273        >>> g = DecisionGraph()
 4274        >>> g.addDecision('A')
 4275        0
 4276        >>> g.addUnexploredEdge('A', 'up')
 4277        1
 4278        >>> g.nameFor(1)
 4279        '_u.0'
 4280        >>> g.decisionTags(1)
 4281        {'unconfirmed': 1}
 4282        >>> g.addUnexploredEdge('A', 'right', 'B')
 4283        2
 4284        >>> g.nameFor(2)
 4285        'B'
 4286        >>> g.decisionTags(2)
 4287        {'unconfirmed': 1}
 4288        >>> g.addUnexploredEdge('A', 'down', None, 'up')
 4289        3
 4290        >>> g.nameFor(3)
 4291        '_u.2'
 4292        >>> g.addUnexploredEdge(
 4293        ...    '_u.0',
 4294        ...    'beyond',
 4295        ...    toDomain='otherDomain',
 4296        ...    tags={'fast':1},
 4297        ...    revTags={'slow':1},
 4298        ...    annotations=['comment'],
 4299        ...    revAnnotations=['one', 'two'],
 4300        ...    requires=base.ReqCapability('dash'),
 4301        ...    revRequires=base.ReqCapability('super dash'),
 4302        ...    consequence=[base.effect(gain='super dash')],
 4303        ...    revConsequece=[base.effect(lose='super dash')]
 4304        ... )
 4305        4
 4306        >>> g.nameFor(4)
 4307        '_u.3'
 4308        >>> g.domainFor(4)
 4309        'otherDomain'
 4310        >>> g.transitionTags('_u.0', 'beyond')
 4311        {'fast': 1}
 4312        >>> g.transitionAnnotations('_u.0', 'beyond')
 4313        ['comment']
 4314        >>> g.getTransitionRequirement('_u.0', 'beyond')
 4315        ReqCapability('dash')
 4316        >>> e = g.getConsequence('_u.0', 'beyond')
 4317        >>> e == [base.effect(gain='super dash')]
 4318        True
 4319        >>> g.transitionTags('_u.3', 'return')
 4320        {'slow': 1}
 4321        >>> g.transitionAnnotations('_u.3', 'return')
 4322        ['one', 'two']
 4323        >>> g.getTransitionRequirement('_u.3', 'return')
 4324        ReqCapability('super dash')
 4325        >>> e = g.getConsequence('_u.3', 'return')
 4326        >>> e == [base.effect(lose='super dash')]
 4327        True
 4328        """
 4329        # Defaults
 4330        if tags is None:
 4331            tags = {}
 4332        if annotations is None:
 4333            annotations = []
 4334        if revTags is None:
 4335            revTags = {}
 4336        if revAnnotations is None:
 4337            revAnnotations = []
 4338
 4339        # Resolve ID
 4340        fromID = self.resolveDecision(fromDecision)
 4341        if toDomain is None:
 4342            toDomain = self.domainFor(fromID)
 4343
 4344        if name in self.destinationsFrom(fromID):
 4345            raise TransitionCollisionError(
 4346                f"Cannot add a new edge {name!r}:"
 4347                f" {self.identityOf(fromDecision)} already has an"
 4348                f" outgoing edge with that name."
 4349            )
 4350
 4351        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 4352            warnings.warn(
 4353                (
 4354                    f"Cannot add a new unexplored node"
 4355                    f" {destinationName!r}: A decision with that name"
 4356                    f" already exists.\n(Leave destinationName as None"
 4357                    f" to use an automatic name.)"
 4358                ),
 4359                DecisionCollisionWarning
 4360            )
 4361
 4362        # Create the new unexplored decision and add the edge
 4363        if destinationName is None:
 4364            toName = '_u.' + str(self.unknownCount)
 4365        else:
 4366            toName = destinationName
 4367        self.unknownCount += 1
 4368        newID = self.addDecision(toName, domain=toDomain)
 4369        self.addTransition(
 4370            fromID,
 4371            name,
 4372            newID,
 4373            tags=tags,
 4374            annotations=annotations
 4375        )
 4376        self.setTransitionRequirement(fromID, name, requires)
 4377        if consequence is not None:
 4378            self.setConsequence(fromID, name, consequence)
 4379
 4380        # Add it to a zone if requested
 4381        if (
 4382            placeInZone is base.DefaultZone
 4383        and toDomain == self.domainFor(fromID)
 4384        ):
 4385            # Add to each parent of the from decision
 4386            for parent in self.zoneParents(fromID):
 4387                self.addDecisionToZone(newID, parent)
 4388        elif placeInZone is not None:
 4389            # Otherwise add it to one specific zone, creating that zone
 4390            # at level 0 if necessary
 4391            assert isinstance(placeInZone, base.Zone)
 4392            if self.getZoneInfo(placeInZone) is None:
 4393                self.createZone(placeInZone, 0)
 4394                # Add new zone to each grandparent of the from decision
 4395                for parent in self.zoneParents(fromID):
 4396                    for grandparent in self.zoneParents(parent):
 4397                        self.addZoneToZone(placeInZone, grandparent)
 4398            self.addDecisionToZone(newID, placeInZone)
 4399
 4400        # Create the reciprocal edge
 4401        if reciprocal is not None:
 4402            self.addTransition(
 4403                newID,
 4404                reciprocal,
 4405                fromID,
 4406                tags=revTags,
 4407                annotations=revAnnotations
 4408            )
 4409            self.setTransitionRequirement(newID, reciprocal, revRequires)
 4410            if revConsequece is not None:
 4411                self.setConsequence(newID, reciprocal, revConsequece)
 4412            # Set as a reciprocal
 4413            self.setReciprocal(fromID, name, reciprocal)
 4414
 4415        # Tag the destination as 'unconfirmed'
 4416        self.tagDecision(newID, 'unconfirmed')
 4417
 4418        # Return ID of new destination
 4419        return newID
 4420
 4421    def retargetTransition(
 4422        self,
 4423        fromDecision: base.AnyDecisionSpecifier,
 4424        transition: base.Transition,
 4425        newDestination: base.AnyDecisionSpecifier,
 4426        swapReciprocal=True,
 4427        errorOnNameColision=True
 4428    ) -> Optional[base.Transition]:
 4429        """
 4430        Given a particular decision and a transition at that decision,
 4431        changes that transition so that it goes to the specified new
 4432        destination instead of wherever it was connected to before. If
 4433        the new destination is the same as the old one, no changes are
 4434        made.
 4435
 4436        If `swapReciprocal` is set to True (the default) then any
 4437        reciprocal edge at the old destination will be deleted, and a
 4438        new reciprocal edge from the new destination with equivalent
 4439        properties to the original reciprocal will be created, pointing
 4440        to the origin of the specified transition. If `swapReciprocal`
 4441        is set to False, then the reciprocal relationship with any old
 4442        reciprocal edge will be removed, but the old reciprocal edge
 4443        will not be changed.
 4444
 4445        Note that if `errorOnNameColision` is True (the default), then
 4446        if the reciprocal transition has the same name as a transition
 4447        which already exists at the new destination node, a
 4448        `TransitionCollisionError` will be thrown. However, if it is set
 4449        to False, the reciprocal transition will be renamed with a suffix
 4450        to avoid any possible name collisions. Either way, the name of
 4451        the reciprocal transition (possibly just changed) will be
 4452        returned, or None if there was no reciprocal transition.
 4453
 4454        ## Example
 4455
 4456        >>> g = DecisionGraph()
 4457        >>> for fr, to, nm in [
 4458        ...     ('A', 'B', 'up'),
 4459        ...     ('A', 'B', 'up2'),
 4460        ...     ('B', 'A', 'down'),
 4461        ...     ('B', 'B', 'self'),
 4462        ...     ('B', 'C', 'next'),
 4463        ...     ('C', 'B', 'prev')
 4464        ... ]:
 4465        ...     if g.getDecision(fr) is None:
 4466        ...        g.addDecision(fr)
 4467        ...     if g.getDecision(to) is None:
 4468        ...         g.addDecision(to)
 4469        ...     g.addTransition(fr, nm, to)
 4470        0
 4471        1
 4472        2
 4473        >>> g.setReciprocal('A', 'up', 'down')
 4474        >>> g.setReciprocal('B', 'next', 'prev')
 4475        >>> g.destination('A', 'up')
 4476        1
 4477        >>> g.destination('B', 'down')
 4478        0
 4479        >>> g.retargetTransition('A', 'up', 'C')
 4480        'down'
 4481        >>> g.destination('A', 'up')
 4482        2
 4483        >>> g.getDestination('B', 'down') is None
 4484        True
 4485        >>> g.destination('C', 'down')
 4486        0
 4487        >>> g.addTransition('A', 'next', 'B')
 4488        >>> g.addTransition('B', 'prev', 'A')
 4489        >>> g.setReciprocal('A', 'next', 'prev')
 4490        >>> # Can't swap a reciprocal in a way that would collide names
 4491        >>> g.getReciprocal('C', 'prev')
 4492        'next'
 4493        >>> g.retargetTransition('C', 'prev', 'A')
 4494        Traceback (most recent call last):
 4495        ...
 4496        exploration.core.TransitionCollisionError...
 4497        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
 4498        'next'
 4499        >>> g.destination('C', 'prev')
 4500        0
 4501        >>> g.destination('A', 'next') # not changed
 4502        1
 4503        >>> # Reciprocal relationship is severed:
 4504        >>> g.getReciprocal('C', 'prev') is None
 4505        True
 4506        >>> g.getReciprocal('B', 'next') is None
 4507        True
 4508        >>> # Swap back so we can do another demo
 4509        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
 4510        >>> # Note return value was None here because there was no reciprocal
 4511        >>> g.setReciprocal('C', 'prev', 'next')
 4512        >>> # Swap reciprocal by renaming it
 4513        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
 4514        'next.1'
 4515        >>> g.getReciprocal('C', 'prev')
 4516        'next.1'
 4517        >>> g.destination('C', 'prev')
 4518        0
 4519        >>> g.destination('A', 'next.1')
 4520        2
 4521        >>> g.destination('A', 'next')
 4522        1
 4523        >>> # Note names are the same but these are from different nodes
 4524        >>> g.getReciprocal('A', 'next')
 4525        'prev'
 4526        >>> g.getReciprocal('A', 'next.1')
 4527        'prev'
 4528        """
 4529        fromID = self.resolveDecision(fromDecision)
 4530        newDestID = self.resolveDecision(newDestination)
 4531
 4532        # Figure out the old destination of the transition we're swapping
 4533        oldDestID = self.destination(fromID, transition)
 4534        reciprocal = self.getReciprocal(fromID, transition)
 4535
 4536        # If thew new destination is the same, we don't do anything!
 4537        if oldDestID == newDestID:
 4538            return reciprocal
 4539
 4540        # First figure out reciprocal business so we can error out
 4541        # without making changes if we need to
 4542        if swapReciprocal and reciprocal is not None:
 4543            reciprocal = self.rebaseTransition(
 4544                oldDestID,
 4545                reciprocal,
 4546                newDestID,
 4547                swapReciprocal=False,
 4548                errorOnNameColision=errorOnNameColision
 4549            )
 4550
 4551        # Handle the forward transition...
 4552        # Find the transition properties
 4553        tProps = self.getTransitionProperties(fromID, transition)
 4554
 4555        # Delete the edge
 4556        self.removeEdgeByKey(fromID, transition)
 4557
 4558        # Add the new edge
 4559        self.addTransition(fromID, transition, newDestID)
 4560
 4561        # Reapply the transition properties
 4562        self.setTransitionProperties(fromID, transition, **tProps)
 4563
 4564        # Handle the reciprocal transition if there is one...
 4565        if reciprocal is not None:
 4566            if not swapReciprocal:
 4567                # Then sever the relationship, but only if that edge
 4568                # still exists (we might be in the middle of a rebase)
 4569                check = self.getDestination(oldDestID, reciprocal)
 4570                if check is not None:
 4571                    self.setReciprocal(
 4572                        oldDestID,
 4573                        reciprocal,
 4574                        None,
 4575                        setBoth=False # Other transition was deleted already
 4576                    )
 4577            else:
 4578                # Establish new reciprocal relationship
 4579                self.setReciprocal(
 4580                    fromID,
 4581                    transition,
 4582                    reciprocal
 4583                )
 4584
 4585        return reciprocal
 4586
 4587    def rebaseTransition(
 4588        self,
 4589        fromDecision: base.AnyDecisionSpecifier,
 4590        transition: base.Transition,
 4591        newBase: base.AnyDecisionSpecifier,
 4592        swapReciprocal=True,
 4593        errorOnNameColision=True
 4594    ) -> base.Transition:
 4595        """
 4596        Given a particular destination and a transition at that
 4597        destination, changes that transition's origin to a new base
 4598        decision. If the new source is the same as the old one, no
 4599        changes are made.
 4600
 4601        If `swapReciprocal` is set to True (the default) then any
 4602        reciprocal edge at the destination will be retargeted to point
 4603        to the new source so that it can remain a reciprocal. If
 4604        `swapReciprocal` is set to False, then the reciprocal
 4605        relationship with any old reciprocal edge will be removed, but
 4606        the old reciprocal edge will not be otherwise changed.
 4607
 4608        Note that if `errorOnNameColision` is True (the default), then
 4609        if the transition has the same name as a transition which
 4610        already exists at the new source node, a
 4611        `TransitionCollisionError` will be raised. However, if it is set
 4612        to False, the transition will be renamed with a suffix to avoid
 4613        any possible name collisions. Either way, the (possibly new) name
 4614        of the transition that was rebased will be returned.
 4615
 4616        ## Example
 4617
 4618        >>> g = DecisionGraph()
 4619        >>> for fr, to, nm in [
 4620        ...     ('A', 'B', 'up'),
 4621        ...     ('A', 'B', 'up2'),
 4622        ...     ('B', 'A', 'down'),
 4623        ...     ('B', 'B', 'self'),
 4624        ...     ('B', 'C', 'next'),
 4625        ...     ('C', 'B', 'prev')
 4626        ... ]:
 4627        ...     if g.getDecision(fr) is None:
 4628        ...        g.addDecision(fr)
 4629        ...     if g.getDecision(to) is None:
 4630        ...         g.addDecision(to)
 4631        ...     g.addTransition(fr, nm, to)
 4632        0
 4633        1
 4634        2
 4635        >>> g.setReciprocal('A', 'up', 'down')
 4636        >>> g.setReciprocal('B', 'next', 'prev')
 4637        >>> g.destination('A', 'up')
 4638        1
 4639        >>> g.destination('B', 'down')
 4640        0
 4641        >>> g.rebaseTransition('B', 'down', 'C')
 4642        'down'
 4643        >>> g.destination('A', 'up')
 4644        2
 4645        >>> g.getDestination('B', 'down') is None
 4646        True
 4647        >>> g.destination('C', 'down')
 4648        0
 4649        >>> g.addTransition('A', 'next', 'B')
 4650        >>> g.addTransition('B', 'prev', 'A')
 4651        >>> g.setReciprocal('A', 'next', 'prev')
 4652        >>> # Can't rebase in a way that would collide names
 4653        >>> g.rebaseTransition('B', 'next', 'A')
 4654        Traceback (most recent call last):
 4655        ...
 4656        exploration.core.TransitionCollisionError...
 4657        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
 4658        'next.1'
 4659        >>> g.destination('C', 'prev')
 4660        0
 4661        >>> g.destination('A', 'next') # not changed
 4662        1
 4663        >>> # Collision is avoided by renaming
 4664        >>> g.destination('A', 'next.1')
 4665        2
 4666        >>> # Swap without reciprocal
 4667        >>> g.getReciprocal('A', 'next.1')
 4668        'prev'
 4669        >>> g.getReciprocal('C', 'prev')
 4670        'next.1'
 4671        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
 4672        'next.1'
 4673        >>> g.getReciprocal('C', 'prev') is None
 4674        True
 4675        >>> g.destination('C', 'prev')
 4676        0
 4677        >>> g.getDestination('A', 'next.1') is None
 4678        True
 4679        >>> g.destination('A', 'next')
 4680        1
 4681        >>> g.destination('B', 'next.1')
 4682        2
 4683        >>> g.getReciprocal('B', 'next.1') is None
 4684        True
 4685        >>> # Rebase in a way that creates a self-edge
 4686        >>> g.rebaseTransition('A', 'next', 'B')
 4687        'next'
 4688        >>> g.getDestination('A', 'next') is None
 4689        True
 4690        >>> g.destination('B', 'next')
 4691        1
 4692        >>> g.destination('B', 'prev') # swapped as a reciprocal
 4693        1
 4694        >>> g.getReciprocal('B', 'next') # still reciprocals
 4695        'prev'
 4696        >>> g.getReciprocal('B', 'prev')
 4697        'next'
 4698        >>> # And rebasing of a self-edge also works
 4699        >>> g.rebaseTransition('B', 'prev', 'A')
 4700        'prev'
 4701        >>> g.destination('A', 'prev')
 4702        1
 4703        >>> g.destination('B', 'next')
 4704        0
 4705        >>> g.getReciprocal('B', 'next') # still reciprocals
 4706        'prev'
 4707        >>> g.getReciprocal('A', 'prev')
 4708        'next'
 4709        >>> # We've effectively reversed this edge/reciprocal pair
 4710        >>> # by rebasing twice
 4711        """
 4712        fromID = self.resolveDecision(fromDecision)
 4713        newBaseID = self.resolveDecision(newBase)
 4714
 4715        # If thew new base is the same, we don't do anything!
 4716        if newBaseID == fromID:
 4717            return transition
 4718
 4719        # First figure out reciprocal business so we can swap it later
 4720        # without making changes if we need to
 4721        destination = self.destination(fromID, transition)
 4722        reciprocal = self.getReciprocal(fromID, transition)
 4723        # Check for an already-deleted reciprocal
 4724        if (
 4725            reciprocal is not None
 4726        and self.getDestination(destination, reciprocal) is None
 4727        ):
 4728            reciprocal = None
 4729
 4730        # Handle the base swap...
 4731        # Find the transition properties
 4732        tProps = self.getTransitionProperties(fromID, transition)
 4733
 4734        # Check for a collision
 4735        targetDestinations = self.destinationsFrom(newBaseID)
 4736        if transition in targetDestinations:
 4737            if errorOnNameColision:
 4738                raise TransitionCollisionError(
 4739                    f"Cannot rebase transition {transition!r} from"
 4740                    f" {self.identityOf(fromDecision)}: it would be a"
 4741                    f" duplicate transition name at the new base"
 4742                    f" decision {self.identityOf(newBase)}."
 4743                )
 4744            else:
 4745                # Figure out a good fresh name
 4746                newName = utils.uniqueName(
 4747                    transition,
 4748                    targetDestinations
 4749                )
 4750        else:
 4751            newName = transition
 4752
 4753        # Delete the edge
 4754        self.removeEdgeByKey(fromID, transition)
 4755
 4756        # Add the new edge
 4757        self.addTransition(newBaseID, newName, destination)
 4758
 4759        # Reapply the transition properties
 4760        self.setTransitionProperties(newBaseID, newName, **tProps)
 4761
 4762        # Handle the reciprocal transition if there is one...
 4763        if reciprocal is not None:
 4764            if not swapReciprocal:
 4765                # Then sever the relationship
 4766                self.setReciprocal(
 4767                    destination,
 4768                    reciprocal,
 4769                    None,
 4770                    setBoth=False # Other transition was deleted already
 4771                )
 4772            else:
 4773                # Otherwise swap the reciprocal edge
 4774                self.retargetTransition(
 4775                    destination,
 4776                    reciprocal,
 4777                    newBaseID,
 4778                    swapReciprocal=False
 4779                )
 4780
 4781                # And establish a new reciprocal relationship
 4782                self.setReciprocal(
 4783                    newBaseID,
 4784                    newName,
 4785                    reciprocal
 4786                )
 4787
 4788        # Return the new name in case it was changed
 4789        return newName
 4790
 4791    # TODO: zone merging!
 4792
 4793    # TODO: Double-check that exploration vars get updated when this is
 4794    # called!
 4795    def mergeDecisions(
 4796        self,
 4797        merge: base.AnyDecisionSpecifier,
 4798        mergeInto: base.AnyDecisionSpecifier,
 4799        errorOnNameColision=True
 4800    ) -> Dict[base.Transition, base.Transition]:
 4801        """
 4802        Merges two decisions, deleting the first after transferring all
 4803        of its incoming and outgoing edges to target the second one,
 4804        whose name is retained. The second decision will be added to any
 4805        zones that the first decision was a member of. If either decision
 4806        does not exist, a `MissingDecisionError` will be raised. If
 4807        `merge` and `mergeInto` are the same, then nothing will be
 4808        changed.
 4809
 4810        Unless `errorOnNameColision` is set to False, a
 4811        `TransitionCollisionError` will be raised if the two decisions
 4812        have outgoing transitions with the same name. If
 4813        `errorOnNameColision` is set to False, then such edges will be
 4814        renamed using a suffix to avoid name collisions, with edges
 4815        connected to the second decision retaining their original names
 4816        and edges that were connected to the first decision getting
 4817        renamed.
 4818
 4819        Any mechanisms located at the first decision will be moved to the
 4820        merged decision.
 4821
 4822        The tags and annotations of the merged decision are added to the
 4823        tags and annotations of the merge target. If there are shared
 4824        tags, the values from the merge target will override those of
 4825        the merged decision. If this is undesired behavior, clear/edit
 4826        the tags/annotations of the merged decision before the merge.
 4827
 4828        The 'unconfirmed' tag is treated specially: if both decisions have
 4829        it it will be retained, but otherwise it will be dropped even if
 4830        one of the situations had it before.
 4831
 4832        The domain of the second decision is retained.
 4833
 4834        Returns a dictionary mapping each original transition name to
 4835        its new name in cases where transitions get renamed; this will
 4836        be empty when no re-naming occurs, including when
 4837        `errorOnNameColision` is True. If there were any transitions
 4838        connecting the nodes that were merged, these become self-edges
 4839        of the merged node (and may be renamed if necessary).
 4840        Note that all renamed transitions were originally based on the
 4841        first (merged) node, since transitions of the second (merge
 4842        target) node are not renamed.
 4843
 4844        ## Example
 4845
 4846        >>> g = DecisionGraph()
 4847        >>> for fr, to, nm in [
 4848        ...     ('A', 'B', 'up'),
 4849        ...     ('A', 'B', 'up2'),
 4850        ...     ('B', 'A', 'down'),
 4851        ...     ('B', 'B', 'self'),
 4852        ...     ('B', 'C', 'next'),
 4853        ...     ('C', 'B', 'prev'),
 4854        ...     ('A', 'C', 'right')
 4855        ... ]:
 4856        ...     if g.getDecision(fr) is None:
 4857        ...        g.addDecision(fr)
 4858        ...     if g.getDecision(to) is None:
 4859        ...         g.addDecision(to)
 4860        ...     g.addTransition(fr, nm, to)
 4861        0
 4862        1
 4863        2
 4864        >>> g.getDestination('A', 'up')
 4865        1
 4866        >>> g.getDestination('B', 'down')
 4867        0
 4868        >>> sorted(g)
 4869        [0, 1, 2]
 4870        >>> g.setReciprocal('A', 'up', 'down')
 4871        >>> g.setReciprocal('B', 'next', 'prev')
 4872        >>> g.mergeDecisions('C', 'B')
 4873        {}
 4874        >>> g.destinationsFrom('A')
 4875        {'up': 1, 'up2': 1, 'right': 1}
 4876        >>> g.destinationsFrom('B')
 4877        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
 4878        >>> 'C' in g
 4879        False
 4880        >>> g.mergeDecisions('A', 'A') # does nothing
 4881        {}
 4882        >>> # Can't merge non-existent decision
 4883        >>> g.mergeDecisions('A', 'Z')
 4884        Traceback (most recent call last):
 4885        ...
 4886        exploration.core.MissingDecisionError...
 4887        >>> g.mergeDecisions('Z', 'A')
 4888        Traceback (most recent call last):
 4889        ...
 4890        exploration.core.MissingDecisionError...
 4891        >>> # Can't merge decisions w/ shared edge names
 4892        >>> g.addDecision('D')
 4893        3
 4894        >>> g.addTransition('D', 'next', 'A')
 4895        >>> g.addTransition('A', 'prev', 'D')
 4896        >>> g.setReciprocal('D', 'next', 'prev')
 4897        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
 4898        Traceback (most recent call last):
 4899        ...
 4900        exploration.core.TransitionCollisionError...
 4901        >>> # Auto-rename colliding edges
 4902        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
 4903        {'next': 'next.1'}
 4904        >>> g.destination('B', 'next') # merge target unchanged
 4905        1
 4906        >>> g.destination('B', 'next.1') # merged decision name changed
 4907        0
 4908        >>> g.destination('B', 'prev') # name unchanged (no collision)
 4909        1
 4910        >>> g.getReciprocal('B', 'next') # unchanged (from B)
 4911        'prev'
 4912        >>> g.getReciprocal('B', 'next.1') # from A
 4913        'prev'
 4914        >>> g.getReciprocal('A', 'prev') # from B
 4915        'next.1'
 4916
 4917        ## Folding four nodes into a 2-node loop
 4918
 4919        >>> g = DecisionGraph()
 4920        >>> g.addDecision('X')
 4921        0
 4922        >>> g.addDecision('Y')
 4923        1
 4924        >>> g.addTransition('X', 'next', 'Y', 'prev')
 4925        >>> g.addDecision('preX')
 4926        2
 4927        >>> g.addDecision('postY')
 4928        3
 4929        >>> g.addTransition('preX', 'next', 'X', 'prev')
 4930        >>> g.addTransition('Y', 'next', 'postY', 'prev')
 4931        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
 4932        {'next': 'next.1'}
 4933        >>> g.destinationsFrom('X')
 4934        {'next': 1, 'prev': 1}
 4935        >>> g.destinationsFrom('Y')
 4936        {'prev': 0, 'next': 3, 'next.1': 0}
 4937        >>> 2 in g
 4938        False
 4939        >>> g.destinationsFrom('postY')
 4940        {'prev': 1}
 4941        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
 4942        {'prev': 'prev.1'}
 4943        >>> g.destinationsFrom('X')
 4944        {'next': 1, 'prev': 1, 'prev.1': 1}
 4945        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
 4946        {'prev': 0, 'next.1': 0, 'next': 0}
 4947        >>> 2 in g
 4948        False
 4949        >>> 3 in g
 4950        False
 4951        >>> # Reciprocals are tangled...
 4952        >>> g.getReciprocal(0, 'prev')
 4953        'next.1'
 4954        >>> g.getReciprocal(0, 'prev.1')
 4955        'next'
 4956        >>> g.getReciprocal(1, 'next')
 4957        'prev.1'
 4958        >>> g.getReciprocal(1, 'next.1')
 4959        'prev'
 4960        >>> # Note: one merge cannot handle both extra transitions
 4961        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
 4962        >>> # (It would merge both edges but the result would retain
 4963        >>> # 'next.1' instead of retaining 'next'.)
 4964        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
 4965        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
 4966        >>> g.destinationsFrom('X')
 4967        {'next': 1, 'prev': 1}
 4968        >>> g.destinationsFrom('Y')
 4969        {'prev': 0, 'next': 0}
 4970        >>> # Reciprocals were salvaged in second merger
 4971        >>> g.getReciprocal('X', 'prev')
 4972        'next'
 4973        >>> g.getReciprocal('Y', 'next')
 4974        'prev'
 4975
 4976        ## Merging with tags/requirements/annotations/consequences
 4977
 4978        >>> g = DecisionGraph()
 4979        >>> g.addDecision('X')
 4980        0
 4981        >>> g.addDecision('Y')
 4982        1
 4983        >>> g.addDecision('Z')
 4984        2
 4985        >>> g.addTransition('X', 'next', 'Y', 'prev')
 4986        >>> g.addTransition('X', 'down', 'Z', 'up')
 4987        >>> g.tagDecision('X', 'tag0', 1)
 4988        >>> g.tagDecision('Y', 'tag1', 10)
 4989        >>> g.tagDecision('Y', 'unconfirmed')
 4990        >>> g.tagDecision('Z', 'tag1', 20)
 4991        >>> g.tagDecision('Z', 'tag2', 30)
 4992        >>> g.tagTransition('X', 'next', 'ttag1', 11)
 4993        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
 4994        >>> g.tagTransition('X', 'down', 'ttag3', 33)
 4995        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
 4996        >>> g.annotateDecision('Y', 'annotation 1')
 4997        >>> g.annotateDecision('Z', 'annotation 2')
 4998        >>> g.annotateDecision('Z', 'annotation 3')
 4999        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
 5000        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
 5001        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
 5002        >>> g.setTransitionRequirement(
 5003        ...     'X',
 5004        ...     'next',
 5005        ...     base.ReqCapability('power')
 5006        ... )
 5007        >>> g.setTransitionRequirement(
 5008        ...     'Y',
 5009        ...     'prev',
 5010        ...     base.ReqTokens('token', 1)
 5011        ... )
 5012        >>> g.setTransitionRequirement(
 5013        ...     'X',
 5014        ...     'down',
 5015        ...     base.ReqCapability('power2')
 5016        ... )
 5017        >>> g.setTransitionRequirement(
 5018        ...     'Z',
 5019        ...     'up',
 5020        ...     base.ReqTokens('token2', 2)
 5021        ... )
 5022        >>> g.setConsequence(
 5023        ...     'Y',
 5024        ...     'prev',
 5025        ...     [base.effect(gain="power2")]
 5026        ... )
 5027        >>> g.mergeDecisions('Y', 'Z')
 5028        {}
 5029        >>> g.destination('X', 'next')
 5030        2
 5031        >>> g.destination('X', 'down')
 5032        2
 5033        >>> g.destination('Z', 'prev')
 5034        0
 5035        >>> g.destination('Z', 'up')
 5036        0
 5037        >>> g.decisionTags('X')
 5038        {'tag0': 1}
 5039        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
 5040        {'tag1': 20, 'tag2': 30}
 5041        >>> g.transitionTags('X', 'next')
 5042        {'ttag1': 11}
 5043        >>> g.transitionTags('X', 'down')
 5044        {'ttag3': 33}
 5045        >>> g.transitionTags('Z', 'prev')
 5046        {'ttag2': 22}
 5047        >>> g.transitionTags('Z', 'up')
 5048        {'ttag4': 44}
 5049        >>> g.decisionAnnotations('Z')
 5050        ['annotation 2', 'annotation 3', 'annotation 1']
 5051        >>> g.transitionAnnotations('Z', 'prev')
 5052        ['trans annotation 1', 'trans annotation 2']
 5053        >>> g.transitionAnnotations('Z', 'up')
 5054        ['trans annotation 3']
 5055        >>> g.getTransitionRequirement('X', 'next')
 5056        ReqCapability('power')
 5057        >>> g.getTransitionRequirement('Z', 'prev')
 5058        ReqTokens('token', 1)
 5059        >>> g.getTransitionRequirement('X', 'down')
 5060        ReqCapability('power2')
 5061        >>> g.getTransitionRequirement('Z', 'up')
 5062        ReqTokens('token2', 2)
 5063        >>> g.getConsequence('Z', 'prev') == [
 5064        ...     {
 5065        ...         'type': 'gain',
 5066        ...         'applyTo': 'active',
 5067        ...         'value': 'power2',
 5068        ...         'charges': None,
 5069        ...         'delay': None,
 5070        ...         'hidden': False
 5071        ...     }
 5072        ... ]
 5073        True
 5074
 5075        ## Merging into node without tags
 5076
 5077        >>> g = DecisionGraph()
 5078        >>> g.addDecision('X')
 5079        0
 5080        >>> g.addDecision('Y')
 5081        1
 5082        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
 5083        >>> g.tagDecision('Y', 'tag', 'value')
 5084        >>> g.mergeDecisions('Y', 'X')
 5085        {}
 5086        >>> g.decisionTags('X')
 5087        {'tag': 'value'}
 5088        >>> 0 in g  # Second argument remains
 5089        True
 5090        >>> 1 in g  # First argument is deleted
 5091        False
 5092        """
 5093        # Resolve IDs
 5094        mergeID = self.resolveDecision(merge)
 5095        mergeIntoID = self.resolveDecision(mergeInto)
 5096
 5097        # Create our result as an empty dictionary
 5098        result: Dict[base.Transition, base.Transition] = {}
 5099
 5100        # Short-circuit if the two decisions are the same
 5101        if mergeID == mergeIntoID:
 5102            return result
 5103
 5104        # MissingDecisionErrors from here if either doesn't exist
 5105        allNewOutgoing = set(self.destinationsFrom(mergeID))
 5106        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
 5107        # Find colliding transition names
 5108        collisions = allNewOutgoing & allOldOutgoing
 5109        if len(collisions) > 0 and errorOnNameColision:
 5110            raise TransitionCollisionError(
 5111                f"Cannot merge decision {self.identityOf(merge)} into"
 5112                f" decision {self.identityOf(mergeInto)}: the decisions"
 5113                f" share {len(collisions)} transition names:"
 5114                f" {collisions}\n(Note that errorOnNameColision was set"
 5115                f" to True, set it to False to allow the operation by"
 5116                f" renaming half of those transitions.)"
 5117            )
 5118
 5119        # Record zones that will have to change after the merge
 5120        zoneParents = self.zoneParents(mergeID)
 5121
 5122        # First, swap all incoming edges, along with their reciprocals
 5123        # This will include self-edges, which will be retargeted and
 5124        # whose reciprocals will be rebased in the process, leading to
 5125        # the possibility of a missing edge during the loop
 5126        for source, incoming in self.allEdgesTo(mergeID):
 5127            # Skip this edge if it was already swapped away because it's
 5128            # a self-loop with a reciprocal whose reciprocal was
 5129            # processed earlier in the loop
 5130            if incoming not in self.destinationsFrom(source):
 5131                continue
 5132
 5133            # Find corresponding outgoing edge
 5134            outgoing = self.getReciprocal(source, incoming)
 5135
 5136            # Swap both edges to new destination
 5137            newOutgoing = self.retargetTransition(
 5138                source,
 5139                incoming,
 5140                mergeIntoID,
 5141                swapReciprocal=True,
 5142                errorOnNameColision=False # collisions were detected above
 5143            )
 5144            # Add to our result if the name of the reciprocal was
 5145            # changed
 5146            if (
 5147                outgoing is not None
 5148            and newOutgoing is not None
 5149            and outgoing != newOutgoing
 5150            ):
 5151                result[outgoing] = newOutgoing
 5152
 5153        # Next, swap any remaining outgoing edges (which didn't have
 5154        # reciprocals, or they'd already be swapped, unless they were
 5155        # self-edges previously). Note that in this loop, there can't be
 5156        # any self-edges remaining, although there might be connections
 5157        # between the merging nodes that need to become self-edges
 5158        # because they used to be a self-edge that was half-retargeted
 5159        # by the previous loop.
 5160        # Note: a copy is used here to avoid iterating over a changing
 5161        # dictionary
 5162        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
 5163            newOutgoing = self.rebaseTransition(
 5164                mergeID,
 5165                stillOutgoing,
 5166                mergeIntoID,
 5167                swapReciprocal=True,
 5168                errorOnNameColision=False # collisions were detected above
 5169            )
 5170            if stillOutgoing != newOutgoing:
 5171                result[stillOutgoing] = newOutgoing
 5172
 5173        # At this point, there shouldn't be any remaining incoming or
 5174        # outgoing edges!
 5175        assert self.degree(mergeID) == 0
 5176
 5177        # Merge tags & annotations
 5178        # Note that these operations affect the underlying graph
 5179        destTags = self.decisionTags(mergeIntoID)
 5180        destUnvisited = 'unconfirmed' in destTags
 5181        sourceTags = self.decisionTags(mergeID)
 5182        sourceUnvisited = 'unconfirmed' in sourceTags
 5183        # Copy over only new tags, leaving existing tags alone
 5184        for key in sourceTags:
 5185            if key not in destTags:
 5186                destTags[key] = sourceTags[key]
 5187
 5188        if int(destUnvisited) + int(sourceUnvisited) == 1:
 5189            del destTags['unconfirmed']
 5190
 5191        self.decisionAnnotations(mergeIntoID).extend(
 5192            self.decisionAnnotations(mergeID)
 5193        )
 5194
 5195        # Transfer zones
 5196        for zone in zoneParents:
 5197            self.addDecisionToZone(mergeIntoID, zone)
 5198
 5199        # Delete the old node
 5200        self.removeDecision(mergeID)
 5201
 5202        return result
 5203
 5204    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
 5205        """
 5206        Deletes the specified decision from the graph, updating
 5207        attendant structures like zones. Note that the ID of the deleted
 5208        node will NOT be reused, unless it's specifically provided to
 5209        `addIdentifiedDecision`.
 5210
 5211        For example:
 5212
 5213        >>> dg = DecisionGraph()
 5214        >>> dg.addDecision('A')
 5215        0
 5216        >>> dg.addDecision('B')
 5217        1
 5218        >>> list(dg)
 5219        [0, 1]
 5220        >>> 1 in dg
 5221        True
 5222        >>> 'B' in dg.nameLookup
 5223        True
 5224        >>> dg.removeDecision('B')
 5225        >>> 1 in dg
 5226        False
 5227        >>> list(dg)
 5228        [0]
 5229        >>> 'B' in dg.nameLookup
 5230        False
 5231        >>> dg.addDecision('C')  # doesn't re-use ID
 5232        2
 5233        """
 5234        dID = self.resolveDecision(decision)
 5235
 5236        # Remove the target from all zones:
 5237        for zone in self.zones:
 5238            self.removeDecisionFromZone(dID, zone)
 5239
 5240        # Remove the node but record the current name
 5241        name = self.nodes[dID]['name']
 5242        self.remove_node(dID)
 5243
 5244        # Clean up the nameLookup entry
 5245        luInfo = self.nameLookup[name]
 5246        luInfo.remove(dID)
 5247        if len(luInfo) == 0:
 5248            self.nameLookup.pop(name)
 5249
 5250        # TODO: Clean up edges?
 5251
 5252    def renameDecision(
 5253        self,
 5254        decision: base.AnyDecisionSpecifier,
 5255        newName: base.DecisionName
 5256    ):
 5257        """
 5258        Renames a decision. The decision retains its old ID.
 5259
 5260        Generates a `DecisionCollisionWarning` if a decision using the new
 5261        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
 5262
 5263        Example:
 5264
 5265        >>> g = DecisionGraph()
 5266        >>> g.addDecision('one')
 5267        0
 5268        >>> g.addDecision('three')
 5269        1
 5270        >>> g.addTransition('one', '>', 'three')
 5271        >>> g.addTransition('three', '<', 'one')
 5272        >>> g.tagDecision('three', 'hi')
 5273        >>> g.annotateDecision('three', 'note')
 5274        >>> g.destination('one', '>')
 5275        1
 5276        >>> g.destination('three', '<')
 5277        0
 5278        >>> g.renameDecision('three', 'two')
 5279        >>> g.resolveDecision('one')
 5280        0
 5281        >>> g.resolveDecision('two')
 5282        1
 5283        >>> g.resolveDecision('three')
 5284        Traceback (most recent call last):
 5285        ...
 5286        exploration.core.MissingDecisionError...
 5287        >>> g.destination('one', '>')
 5288        1
 5289        >>> g.nameFor(1)
 5290        'two'
 5291        >>> g.getDecision('three') is None
 5292        True
 5293        >>> g.destination('two', '<')
 5294        0
 5295        >>> g.decisionTags('two')
 5296        {'hi': 1}
 5297        >>> g.decisionAnnotations('two')
 5298        ['note']
 5299        """
 5300        dID = self.resolveDecision(decision)
 5301
 5302        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
 5303            warnings.warn(
 5304                (
 5305                    f"Can't rename {self.identityOf(decision)} as"
 5306                    f" {newName!r} because a decision with that name"
 5307                    f" already exists."
 5308                ),
 5309                DecisionCollisionWarning
 5310            )
 5311
 5312        # Update name in node
 5313        oldName = self.nodes[dID]['name']
 5314        self.nodes[dID]['name'] = newName
 5315
 5316        # Update nameLookup entries
 5317        oldNL = self.nameLookup[oldName]
 5318        oldNL.remove(dID)
 5319        if len(oldNL) == 0:
 5320            self.nameLookup.pop(oldName)
 5321        self.nameLookup.setdefault(newName, []).append(dID)
 5322
 5323    def mergeTransitions(
 5324        self,
 5325        fromDecision: base.AnyDecisionSpecifier,
 5326        merge: base.Transition,
 5327        mergeInto: base.Transition,
 5328        mergeReciprocal=True
 5329    ) -> None:
 5330        """
 5331        Given a decision and two transitions that start at that decision,
 5332        merges the first transition into the second transition, combining
 5333        their transition properties (using `mergeProperties`) and
 5334        deleting the first transition. By default any reciprocal of the
 5335        first transition is also merged into the reciprocal of the
 5336        second, although you can set `mergeReciprocal` to `False` to
 5337        disable this in which case the old reciprocal will lose its
 5338        reciprocal relationship, even if the transition that was merged
 5339        into does not have a reciprocal.
 5340
 5341        If the two names provided are the same, nothing will happen.
 5342
 5343        If the two transitions do not share the same destination, they
 5344        cannot be merged, and an `InvalidDestinationError` will result.
 5345        Use `retargetTransition` beforehand to ensure that they do if you
 5346        want to merge transitions with different destinations.
 5347
 5348        A `MissingDecisionError` or `MissingTransitionError` will result
 5349        if the decision or either transition does not exist.
 5350
 5351        If merging reciprocal properties was requested and the first
 5352        transition does not have a reciprocal, then no reciprocal
 5353        properties change. However, if the second transition does not
 5354        have a reciprocal and the first does, the first transition's
 5355        reciprocal will be set to the reciprocal of the second
 5356        transition, and that transition will not be deleted as usual.
 5357
 5358        ## Example
 5359
 5360        >>> g = DecisionGraph()
 5361        >>> g.addDecision('A')
 5362        0
 5363        >>> g.addDecision('B')
 5364        1
 5365        >>> g.addTransition('A', 'up', 'B')
 5366        >>> g.addTransition('B', 'down', 'A')
 5367        >>> g.setReciprocal('A', 'up', 'down')
 5368        >>> # Merging a transition with no reciprocal
 5369        >>> g.addTransition('A', 'up2', 'B')
 5370        >>> g.mergeTransitions('A', 'up2', 'up')
 5371        >>> g.getDestination('A', 'up2') is None
 5372        True
 5373        >>> g.getDestination('A', 'up')
 5374        1
 5375        >>> # Merging a transition with a reciprocal & tags
 5376        >>> g.addTransition('A', 'up2', 'B')
 5377        >>> g.addTransition('B', 'down2', 'A')
 5378        >>> g.setReciprocal('A', 'up2', 'down2')
 5379        >>> g.tagTransition('A', 'up2', 'one')
 5380        >>> g.tagTransition('B', 'down2', 'two')
 5381        >>> g.mergeTransitions('B', 'down2', 'down')
 5382        >>> g.getDestination('A', 'up2') is None
 5383        True
 5384        >>> g.getDestination('A', 'up')
 5385        1
 5386        >>> g.getDestination('B', 'down2') is None
 5387        True
 5388        >>> g.getDestination('B', 'down')
 5389        0
 5390        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
 5391        >>> g.addTransition('A', 'up2', 'B')
 5392        >>> g.setTransitionProperties(
 5393        ...     'A',
 5394        ...     'up2',
 5395        ...     requirement=base.ReqCapability('dash')
 5396        ... )
 5397        >>> g.setTransitionProperties('A', 'up',
 5398        ...     requirement=base.ReqCapability('slide'))
 5399        >>> g.mergeTransitions('A', 'up2', 'up')
 5400        >>> g.getDestination('A', 'up2') is None
 5401        True
 5402        >>> repr(g.getTransitionRequirement('A', 'up'))
 5403        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
 5404        >>> # Errors if destinations differ, or if something is missing
 5405        >>> g.mergeTransitions('A', 'down', 'up')
 5406        Traceback (most recent call last):
 5407        ...
 5408        exploration.core.MissingTransitionError...
 5409        >>> g.mergeTransitions('Z', 'one', 'two')
 5410        Traceback (most recent call last):
 5411        ...
 5412        exploration.core.MissingDecisionError...
 5413        >>> g.addDecision('C')
 5414        2
 5415        >>> g.addTransition('A', 'down', 'C')
 5416        >>> g.mergeTransitions('A', 'down', 'up')
 5417        Traceback (most recent call last):
 5418        ...
 5419        exploration.core.InvalidDestinationError...
 5420        >>> # Merging a reciprocal onto an edge that doesn't have one
 5421        >>> g.addTransition('A', 'down2', 'C')
 5422        >>> g.addTransition('C', 'up2', 'A')
 5423        >>> g.setReciprocal('A', 'down2', 'up2')
 5424        >>> g.tagTransition('C', 'up2', 'narrow')
 5425        >>> g.getReciprocal('A', 'down') is None
 5426        True
 5427        >>> g.mergeTransitions('A', 'down2', 'down')
 5428        >>> g.getDestination('A', 'down2') is None
 5429        True
 5430        >>> g.getDestination('A', 'down')
 5431        2
 5432        >>> g.getDestination('C', 'up2')
 5433        0
 5434        >>> g.getReciprocal('A', 'down')
 5435        'up2'
 5436        >>> g.getReciprocal('C', 'up2')
 5437        'down'
 5438        >>> g.transitionTags('C', 'up2')
 5439        {'narrow': 1}
 5440        >>> # Merging without a reciprocal
 5441        >>> g.addTransition('C', 'up', 'A')
 5442        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
 5443        >>> g.getDestination('C', 'up2') is None
 5444        True
 5445        >>> g.getDestination('C', 'up')
 5446        0
 5447        >>> g.transitionTags('C', 'up') # tag gets merged
 5448        {'narrow': 1}
 5449        >>> g.getDestination('A', 'down')
 5450        2
 5451        >>> g.getReciprocal('A', 'down') is None
 5452        True
 5453        >>> g.getReciprocal('C', 'up') is None
 5454        True
 5455        >>> # Merging w/ normal reciprocals
 5456        >>> g.addDecision('D')
 5457        3
 5458        >>> g.addDecision('E')
 5459        4
 5460        >>> g.addTransition('D', 'up', 'E', 'return')
 5461        >>> g.addTransition('E', 'down', 'D')
 5462        >>> g.mergeTransitions('E', 'return', 'down')
 5463        >>> g.getDestination('D', 'up')
 5464        4
 5465        >>> g.getDestination('E', 'down')
 5466        3
 5467        >>> g.getDestination('E', 'return') is None
 5468        True
 5469        >>> g.getReciprocal('D', 'up')
 5470        'down'
 5471        >>> g.getReciprocal('E', 'down')
 5472        'up'
 5473        >>> # Merging w/ weird reciprocals
 5474        >>> g.addTransition('E', 'return', 'D')
 5475        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
 5476        >>> g.getReciprocal('D', 'up')
 5477        'down'
 5478        >>> g.getReciprocal('E', 'down')
 5479        'up'
 5480        >>> g.getReciprocal('E', 'return') # shared
 5481        'up'
 5482        >>> g.mergeTransitions('E', 'return', 'down')
 5483        >>> g.getDestination('D', 'up')
 5484        4
 5485        >>> g.getDestination('E', 'down')
 5486        3
 5487        >>> g.getDestination('E', 'return') is None
 5488        True
 5489        >>> g.getReciprocal('D', 'up')
 5490        'down'
 5491        >>> g.getReciprocal('E', 'down')
 5492        'up'
 5493        """
 5494        fromID = self.resolveDecision(fromDecision)
 5495
 5496        # Short-circuit in the no-op case
 5497        if merge == mergeInto:
 5498            return
 5499
 5500        # These lines will raise a MissingDecisionError or
 5501        # MissingTransitionError if needed
 5502        dest1 = self.destination(fromID, merge)
 5503        dest2 = self.destination(fromID, mergeInto)
 5504
 5505        if dest1 != dest2:
 5506            raise InvalidDestinationError(
 5507                f"Cannot merge transition {merge!r} into transition"
 5508                f" {mergeInto!r} from decision"
 5509                f" {self.identityOf(fromDecision)} because their"
 5510                f" destinations are different ({self.identityOf(dest1)}"
 5511                f" and {self.identityOf(dest2)}).\nNote: you can use"
 5512                f" `retargetTransition` to change the destination of a"
 5513                f" transition."
 5514            )
 5515
 5516        # Find and the transition properties
 5517        props1 = self.getTransitionProperties(fromID, merge)
 5518        props2 = self.getTransitionProperties(fromID, mergeInto)
 5519        merged = mergeProperties(props1, props2)
 5520        # Note that this doesn't change the reciprocal:
 5521        self.setTransitionProperties(fromID, mergeInto, **merged)
 5522
 5523        # Merge the reciprocal properties if requested
 5524        # Get reciprocal to merge into
 5525        reciprocal = self.getReciprocal(fromID, mergeInto)
 5526        # Get reciprocal that needs cleaning up
 5527        altReciprocal = self.getReciprocal(fromID, merge)
 5528        # If the reciprocal to be merged actually already was the
 5529        # reciprocal to merge into, there's nothing to do here
 5530        if altReciprocal != reciprocal:
 5531            if not mergeReciprocal:
 5532                # In this case, we sever the reciprocal relationship if
 5533                # there is a reciprocal
 5534                if altReciprocal is not None:
 5535                    self.setReciprocal(dest1, altReciprocal, None)
 5536                    # By default setBoth takes care of the other half
 5537            else:
 5538                # In this case, we try to merge reciprocals
 5539                # If altReciprocal is None, we don't need to do anything
 5540                if altReciprocal is not None:
 5541                    # Was there already a reciprocal or not?
 5542                    if reciprocal is None:
 5543                        # altReciprocal becomes the new reciprocal and is
 5544                        # not deleted
 5545                        self.setReciprocal(
 5546                            fromID,
 5547                            mergeInto,
 5548                            altReciprocal
 5549                        )
 5550                    else:
 5551                        # merge reciprocal properties
 5552                        props1 = self.getTransitionProperties(
 5553                            dest1,
 5554                            altReciprocal
 5555                        )
 5556                        props2 = self.getTransitionProperties(
 5557                            dest2,
 5558                            reciprocal
 5559                        )
 5560                        merged = mergeProperties(props1, props2)
 5561                        self.setTransitionProperties(
 5562                            dest1,
 5563                            reciprocal,
 5564                            **merged
 5565                        )
 5566
 5567                        # delete the old reciprocal transition
 5568                        self.remove_edge(dest1, fromID, altReciprocal)
 5569
 5570        # Delete the old transition (reciprocal deletion/severance is
 5571        # handled above if necessary)
 5572        self.remove_edge(fromID, dest1, merge)
 5573
 5574    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
 5575        """
 5576        Returns `True` or `False` depending on whether or not the
 5577        specified decision has been confirmed. Uses the presence or
 5578        absence of the 'unconfirmed' tag to determine this.
 5579
 5580        Note: 'unconfirmed' is used instead of 'confirmed' so that large
 5581        graphs with many confirmed nodes will be smaller when saved.
 5582        """
 5583        dID = self.resolveDecision(decision)
 5584
 5585        return 'unconfirmed' not in self.nodes[dID]['tags']
 5586
 5587    def replaceUnconfirmed(
 5588        self,
 5589        fromDecision: base.AnyDecisionSpecifier,
 5590        transition: base.Transition,
 5591        connectTo: Optional[base.AnyDecisionSpecifier] = None,
 5592        reciprocal: Optional[base.Transition] = None,
 5593        requirement: Optional[base.Requirement] = None,
 5594        applyConsequence: Optional[base.Consequence] = None,
 5595        placeInZone: Union[type[base.DefaultZone], base.Zone, None] = None,
 5596        forceNew: bool = False,
 5597        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
 5598        annotations: Optional[List[base.Annotation]] = None,
 5599        revRequires: Optional[base.Requirement] = None,
 5600        revConsequence: Optional[base.Consequence] = None,
 5601        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 5602        revAnnotations: Optional[List[base.Annotation]] = None,
 5603        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
 5604        decisionAnnotations: Optional[List[base.Annotation]] = None
 5605    ) -> Tuple[
 5606        Dict[base.Transition, base.Transition],
 5607        Dict[base.Transition, base.Transition]
 5608    ]:
 5609        """
 5610        Given a decision and an edge name in that decision, where the
 5611        named edge leads to a decision with an unconfirmed exploration
 5612        state (see `isConfirmed`), renames the unexplored decision on
 5613        the other end of that edge using the given `connectTo` name, or
 5614        if a decision using that name already exists, merges the
 5615        unexplored decision into that decision. If `connectTo` is a
 5616        `DecisionSpecifier` whose target doesn't exist, it will be
 5617        treated as just a name, but if it's an ID and it doesn't exist,
 5618        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
 5619        a reciprocal edge will be added using that name connecting the
 5620        `connectTo` decision back to the original decision. If this
 5621        transition already exists, it must also point to a node which is
 5622        also unexplored, and which will also be merged into the
 5623        `fromDecision` node.
 5624
 5625        If `connectTo` is not given (or is set to `None` explicitly)
 5626        then the name of the unexplored decision will not be changed,
 5627        unless that name has the form `'_u.-n-'` where `-n-` is a positive
 5628        integer (i.e., the form given to automatically-named unknown
 5629        nodes). In that case, the name will be changed to `'_x.-n-'` using
 5630        the same number, or a higher number if that name is already taken.
 5631
 5632        If the destination is being renamed or if the destination's
 5633        exploration state counts as unexplored, the exploration state of
 5634        the destination will be set to 'exploring'.
 5635
 5636        If a `placeInZone` is specified, the destination will be placed
 5637        directly into that zone (even if it already existed and has zone
 5638        information), and it will be removed from any other zones it had
 5639        been a direct member of. If `placeInZone` is set to
 5640        `DefaultZone`, then the destination will be placed into each zone
 5641        which is a direct parent of the origin, but only if the
 5642        destination is not an already-explored existing decision AND
 5643        it is not already in any zones (in those cases no zone changes
 5644        are made). This will also remove it from any previous zones it
 5645        had been a part of. If `placeInZone` is left as `None` (the
 5646        default) no zone changes are made.
 5647
 5648        If `placeInZone` is specified and that zone didn't already exist,
 5649        it will be created as a new level-0 zone and will be added as a
 5650        sub-zone of each zone that's a direct parent of any level-0 zone
 5651        that the origin is a member of.
 5652
 5653        If `forceNew` is specified, then the destination will just be
 5654        renamed, even if another decision with the same name already
 5655        exists. It's an error to use `forceNew` with a decision ID as
 5656        the destination.
 5657
 5658        Any additional edges pointing to or from the unknown node(s)
 5659        being replaced will also be re-targeted at the now-discovered
 5660        known destination(s) if necessary. These edges will retain their
 5661        reciprocal names, or if this would cause a name clash, they will
 5662        be renamed with a suffix (see `retargetTransition`).
 5663
 5664        The return value is a pair of dictionaries mapping old names to
 5665        new ones that just includes the names which were changed. The
 5666        first dictionary contains renamed transitions that are outgoing
 5667        from the new destination node (which used to be outgoing from
 5668        the unexplored node). The second dictionary contains renamed
 5669        transitions that are outgoing from the source node (which used
 5670        to be outgoing from the unexplored node attached to the
 5671        reciprocal transition; if there was no reciprocal transition
 5672        specified then this will always be an empty dictionary).
 5673
 5674        An `ExplorationStatusError` will be raised if the destination
 5675        of the specified transition counts as visited (see
 5676        `hasBeenVisited`). An `ExplorationStatusError` will also be
 5677        raised if the `connectTo`'s `reciprocal` transition does not lead
 5678        to an unconfirmed decision (it's okay if this second transition
 5679        doesn't exist). A `TransitionCollisionError` will be raised if
 5680        the unconfirmed destination decision already has an outgoing
 5681        transition with the specified `reciprocal` which does not lead
 5682        back to the `fromDecision`.
 5683
 5684        The transition properties (requirement, consequences, tags,
 5685        and/or annotations) of the replaced transition will be copied
 5686        over to the new transition. Transition properties from the
 5687        reciprocal transition will also be copied for the newly created
 5688        reciprocal edge. Properties for any additional edges to/from the
 5689        unknown node will also be copied.
 5690
 5691        Also, any transition properties on existing forward or reciprocal
 5692        edges from the destination node with the indicated reverse name
 5693        will be merged with those from the target transition. Note that
 5694        this merging process may introduce corruption of complex
 5695        transition consequences. TODO: Fix that!
 5696
 5697        Any tags and annotations are added to copied tags/annotations,
 5698        but specified requirements, and/or consequences will replace
 5699        previous requirements/consequences, rather than being added to
 5700        them.
 5701
 5702        ## Example
 5703
 5704        >>> g = DecisionGraph()
 5705        >>> g.addDecision('A')
 5706        0
 5707        >>> g.addUnexploredEdge('A', 'up')
 5708        1
 5709        >>> g.destination('A', 'up')
 5710        1
 5711        >>> g.destination('_u.0', 'return')
 5712        0
 5713        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
 5714        ({}, {})
 5715        >>> g.destination('A', 'up')
 5716        1
 5717        >>> g.nameFor(1)
 5718        'B'
 5719        >>> g.destination('B', 'down')
 5720        0
 5721        >>> g.getDestination('B', 'return') is None
 5722        True
 5723        >>> '_u.0' in g.nameLookup
 5724        False
 5725        >>> g.getReciprocal('A', 'up')
 5726        'down'
 5727        >>> g.getReciprocal('B', 'down')
 5728        'up'
 5729        >>> # Two unexplored edges to the same node:
 5730        >>> g.addDecision('C')
 5731        2
 5732        >>> g.addTransition('B', 'next', 'C')
 5733        >>> g.addTransition('C', 'prev', 'B')
 5734        >>> g.setReciprocal('B', 'next', 'prev')
 5735        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
 5736        3
 5737        >>> g.addTransition('C', 'down', 'D')
 5738        >>> g.addTransition('D', 'up', 'C')
 5739        >>> g.setReciprocal('C', 'down', 'up')
 5740        >>> g.replaceUnconfirmed('C', 'down')
 5741        ({}, {})
 5742        >>> g.destination('C', 'down')
 5743        3
 5744        >>> g.destination('A', 'next')
 5745        3
 5746        >>> g.destinationsFrom('D')
 5747        {'prev': 0, 'up': 2}
 5748        >>> g.decisionTags('D')
 5749        {}
 5750        >>> # An unexplored transition which turns out to connect to a
 5751        >>> # known decision, with name collisions
 5752        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
 5753        4
 5754        >>> g.tagDecision('_u.2', 'wet')
 5755        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
 5756        Traceback (most recent call last):
 5757        ...
 5758        exploration.core.TransitionCollisionError...
 5759        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
 5760        5
 5761        >>> g.tagDecision('_u.3', 'dry')
 5762        >>> # Add transitions that will collide when merged
 5763        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
 5764        6
 5765        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
 5766        7
 5767        >>> g.getReciprocal('A', 'prev')
 5768        'next'
 5769        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
 5770        ({'prev': 'prev.1'}, {'up': 'up.1'})
 5771        >>> g.destination('A', 'prev')
 5772        3
 5773        >>> g.destination('D', 'next')
 5774        0
 5775        >>> g.getReciprocal('A', 'prev')
 5776        'next'
 5777        >>> g.getReciprocal('D', 'next')
 5778        'prev'
 5779        >>> # Note that further unexplored structures are NOT merged
 5780        >>> # even if they match against existing structures...
 5781        >>> g.destination('A', 'up.1')
 5782        6
 5783        >>> g.destination('D', 'prev.1')
 5784        7
 5785        >>> '_u.2' in g.nameLookup
 5786        False
 5787        >>> '_u.3' in g.nameLookup
 5788        False
 5789        >>> g.decisionTags('D') # tags are merged
 5790        {'dry': 1}
 5791        >>> g.decisionTags('A')
 5792        {'wet': 1}
 5793        >>> # Auto-renaming an anonymous unexplored node
 5794        >>> g.addUnexploredEdge('B', 'out')
 5795        8
 5796        >>> g.replaceUnconfirmed('B', 'out')
 5797        ({}, {})
 5798        >>> '_u.6' in g
 5799        False
 5800        >>> g.destination('B', 'out')
 5801        8
 5802        >>> g.nameFor(8)
 5803        '_x.6'
 5804        >>> g.destination('_x.6', 'return')
 5805        1
 5806        >>> # Placing a node into a zone
 5807        >>> g.addUnexploredEdge('B', 'through')
 5808        9
 5809        >>> g.getDecision('E') is None
 5810        True
 5811        >>> g.replaceUnconfirmed(
 5812        ...     'B',
 5813        ...     'through',
 5814        ...     'E',
 5815        ...     'back',
 5816        ...     placeInZone='Zone'
 5817        ... )
 5818        ({}, {})
 5819        >>> g.getDecision('E')
 5820        9
 5821        >>> g.destination('B', 'through')
 5822        9
 5823        >>> g.destination('E', 'back')
 5824        1
 5825        >>> g.zoneParents(9)
 5826        {'Zone'}
 5827        >>> g.addUnexploredEdge('E', 'farther')
 5828        10
 5829        >>> g.replaceUnconfirmed(
 5830        ...     'E',
 5831        ...     'farther',
 5832        ...     'F',
 5833        ...     'closer',
 5834        ...     placeInZone=base.DefaultZone
 5835        ... )
 5836        ({}, {})
 5837        >>> g.destination('E', 'farther')
 5838        10
 5839        >>> g.destination('F', 'closer')
 5840        9
 5841        >>> g.zoneParents(10)
 5842        {'Zone'}
 5843        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
 5844        11
 5845        >>> g.replaceUnconfirmed(
 5846        ...     'F',
 5847        ...     'backwards',
 5848        ...     'G',
 5849        ...     'forwards',
 5850        ...     placeInZone=base.DefaultZone
 5851        ... )
 5852        ({}, {})
 5853        >>> g.destination('F', 'backwards')
 5854        11
 5855        >>> g.destination('G', 'forwards')
 5856        10
 5857        >>> g.zoneParents(11)  # not changed since it already had a zone
 5858        {'Enoz'}
 5859        >>> # TODO: forceNew example
 5860        """
 5861
 5862        # Defaults
 5863        if tags is None:
 5864            tags = {}
 5865        if annotations is None:
 5866            annotations = []
 5867        if revTags is None:
 5868            revTags = {}
 5869        if revAnnotations is None:
 5870            revAnnotations = []
 5871        if decisionTags is None:
 5872            decisionTags = {}
 5873        if decisionAnnotations is None:
 5874            decisionAnnotations = []
 5875
 5876        # Resolve source
 5877        fromID = self.resolveDecision(fromDecision)
 5878
 5879        # Figure out destination decision
 5880        oldUnexplored = self.destination(fromID, transition)
 5881        if self.isConfirmed(oldUnexplored):
 5882            raise ExplorationStatusError(
 5883                f"Transition {transition!r} from"
 5884                f" {self.identityOf(fromDecision)} does not lead to an"
 5885                f" unconfirmed decision (it leads to"
 5886                f" {self.identityOf(oldUnexplored)} which is not tagged"
 5887                f" 'unconfirmed')."
 5888            )
 5889
 5890        # Resolve destination
 5891        newName: Optional[base.DecisionName] = None
 5892        connectID: Optional[base.DecisionID] = None
 5893        if forceNew:
 5894            if isinstance(connectTo, base.DecisionID):
 5895                raise TypeError(
 5896                    f"connectTo cannot be a decision ID when forceNew"
 5897                    f" is True. Got: {self.identityOf(connectTo)}"
 5898                )
 5899            elif isinstance(connectTo, base.DecisionSpecifier):
 5900                newName = connectTo.name
 5901            elif isinstance(connectTo, base.DecisionName):
 5902                newName = connectTo
 5903            elif connectTo is None:
 5904                oldName = self.nameFor(oldUnexplored)
 5905                if (
 5906                    oldName.startswith('_u.')
 5907                and oldName[3:].isdigit()
 5908                ):
 5909                    newName = utils.uniqueName('_x.' + oldName[3:], self)
 5910                else:
 5911                    newName = oldName
 5912            else:
 5913                raise TypeError(
 5914                    f"Invalid connectTo value: {connectTo!r}"
 5915                )
 5916        elif connectTo is not None:
 5917            try:
 5918                connectID = self.resolveDecision(connectTo)
 5919                # leave newName as None
 5920            except MissingDecisionError:
 5921                if isinstance(connectTo, int):
 5922                    raise
 5923                elif isinstance(connectTo, base.DecisionSpecifier):
 5924                    newName = connectTo.name
 5925                    # The domain & zone are ignored here
 5926                else:  # Must just be a string
 5927                    assert isinstance(connectTo, str)
 5928                    newName = connectTo
 5929        else:
 5930            # If connectTo name wasn't specified, use current name of
 5931            # unknown node unless it's a default name
 5932            oldName = self.nameFor(oldUnexplored)
 5933            if (
 5934                oldName.startswith('_u.')
 5935            and oldName[3:].isdigit()
 5936            ):
 5937                newName = utils.uniqueName('_x.' + oldName[3:], self)
 5938            else:
 5939                newName = oldName
 5940
 5941        # One or the other should be valid at this point
 5942        assert connectID is not None or newName is not None
 5943
 5944        # Check that the old unknown doesn't have a reciprocal edge that
 5945        # would collide with the specified return edge
 5946        if reciprocal is not None:
 5947            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
 5948            if revFromUnknown not in (None, fromID):
 5949                raise TransitionCollisionError(
 5950                    f"Transition {reciprocal!r} from"
 5951                    f" {self.identityOf(oldUnexplored)} exists and does"
 5952                    f" not lead back to {self.identityOf(fromDecision)}"
 5953                    f" (it leads to {self.identityOf(revFromUnknown)})."
 5954                )
 5955
 5956        # Remember old reciprocal edge for future merging in case
 5957        # it's not reciprocal
 5958        oldReciprocal = self.getReciprocal(fromID, transition)
 5959
 5960        # Apply any new tags or annotations, or create a new node
 5961        needsZoneInfo = False
 5962        if connectID is not None:
 5963            # Before applying tags, check if we need to error out
 5964            # because of a reciprocal edge that points to a known
 5965            # destination:
 5966            if reciprocal is not None:
 5967                otherOldUnknown: Optional[
 5968                    base.DecisionID
 5969                ] = self.getDestination(
 5970                    connectID,
 5971                    reciprocal
 5972                )
 5973                if (
 5974                    otherOldUnknown is not None
 5975                and self.isConfirmed(otherOldUnknown)
 5976                ):
 5977                    raise ExplorationStatusError(
 5978                        f"Reciprocal transition {reciprocal!r} from"
 5979                        f" {self.identityOf(connectTo)} does not lead"
 5980                        f" to an unconfirmed decision (it leads to"
 5981                        f" {self.identityOf(otherOldUnknown)})."
 5982                    )
 5983            self.tagDecision(connectID, decisionTags)
 5984            self.annotateDecision(connectID, decisionAnnotations)
 5985            # Still needs zone info if the place we're connecting to was
 5986            # unconfirmed up until now, since unconfirmed nodes don't
 5987            # normally get zone info when they're created.
 5988            if not self.isConfirmed(connectID):
 5989                needsZoneInfo = True
 5990
 5991            # First, merge the old unknown with the connectTo node...
 5992            destRenames = self.mergeDecisions(
 5993                oldUnexplored,
 5994                connectID,
 5995                errorOnNameColision=False
 5996            )
 5997        else:
 5998            needsZoneInfo = True
 5999            if len(self.zoneParents(oldUnexplored)) > 0:
 6000                needsZoneInfo = False
 6001            assert newName is not None
 6002            self.renameDecision(oldUnexplored, newName)
 6003            connectID = oldUnexplored
 6004            # In this case there can't be an other old unknown
 6005            otherOldUnknown = None
 6006            destRenames = {}  # empty
 6007
 6008        # Check for domain mismatch to stifle zone updates:
 6009        fromDomain = self.domainFor(fromID)
 6010        if connectID is None:
 6011            destDomain = self.domainFor(oldUnexplored)
 6012        else:
 6013            destDomain = self.domainFor(connectID)
 6014
 6015        # Stifle zone updates if there's a mismatch
 6016        if fromDomain != destDomain:
 6017            needsZoneInfo = False
 6018
 6019        # Records renames that happen at the source (from node)
 6020        sourceRenames = {}  # empty for now
 6021
 6022        assert connectID is not None
 6023
 6024        # Apply the new zone if there is one
 6025        if placeInZone is not None:
 6026            if placeInZone is base.DefaultZone:
 6027                # When using DefaultZone, changes are only made for new
 6028                # destinations which don't already have any zones and
 6029                # which are in the same domain as the departing node:
 6030                # they get placed into each zone parent of the source
 6031                # decision.
 6032                if needsZoneInfo:
 6033                    # Remove destination from all current parents
 6034                    removeFrom = set(self.zoneParents(connectID))  # copy
 6035                    for parent in removeFrom:
 6036                        self.removeDecisionFromZone(connectID, parent)
 6037                    # Add it to parents of origin
 6038                    for parent in self.zoneParents(fromID):
 6039                        self.addDecisionToZone(connectID, parent)
 6040            else:
 6041                placeInZone = cast(base.Zone, placeInZone)
 6042                # Create the zone if it doesn't already exist
 6043                if self.getZoneInfo(placeInZone) is None:
 6044                    self.createZone(placeInZone, 0)
 6045                    # Add it to each grandparent of the from decision
 6046                    for parent in self.zoneParents(fromID):
 6047                        for grandparent in self.zoneParents(parent):
 6048                            self.addZoneToZone(placeInZone, grandparent)
 6049                # Remove destination from all current parents
 6050                for parent in set(self.zoneParents(connectID)):
 6051                    self.removeDecisionFromZone(connectID, parent)
 6052                # Add it to the specified zone
 6053                self.addDecisionToZone(connectID, placeInZone)
 6054
 6055        # Next, if there is a reciprocal name specified, we do more...
 6056        if reciprocal is not None:
 6057            # Figure out what kind of merging needs to happen
 6058            if otherOldUnknown is None:
 6059                if revFromUnknown is None:
 6060                    # Just create the desired reciprocal transition, which
 6061                    # we know does not already exist
 6062                    self.addTransition(connectID, reciprocal, fromID)
 6063                    otherOldReciprocal = None
 6064                else:
 6065                    # Reciprocal exists, as revFromUnknown
 6066                    otherOldReciprocal = None
 6067            else:
 6068                otherOldReciprocal = self.getReciprocal(
 6069                    connectID,
 6070                    reciprocal
 6071                )
 6072                # we need to merge otherOldUnknown into our fromDecision
 6073                sourceRenames = self.mergeDecisions(
 6074                    otherOldUnknown,
 6075                    fromID,
 6076                    errorOnNameColision=False
 6077                )
 6078                # Unvisited tag after merge only if both were
 6079
 6080            # No matter what happened we ensure the reciprocal
 6081            # relationship is set up:
 6082            self.setReciprocal(fromID, transition, reciprocal)
 6083
 6084            # Now we might need to merge some transitions:
 6085            # - Any reciprocal of the target transition should be merged
 6086            #   with reciprocal (if it was already reciprocal, that's a
 6087            #   no-op).
 6088            # - Any reciprocal of the reciprocal transition from the target
 6089            #   node (leading to otherOldUnknown) should be merged with
 6090            #   the target transition, even if it shared a name and was
 6091            #   renamed as a result.
 6092            # - If reciprocal was renamed during the initial merge, those
 6093            #   transitions should be merged.
 6094
 6095            # Merge old reciprocal into reciprocal
 6096            if oldReciprocal is not None:
 6097                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
 6098                if self.getDestination(connectID, oldRev) is not None:
 6099                    # Note that we don't want to auto-merge the reciprocal,
 6100                    # which is the target transition
 6101                    self.mergeTransitions(
 6102                        connectID,
 6103                        oldRev,
 6104                        reciprocal,
 6105                        mergeReciprocal=False
 6106                    )
 6107                    # Remove it from the renames map
 6108                    if oldReciprocal in destRenames:
 6109                        del destRenames[oldReciprocal]
 6110
 6111            # Merge reciprocal reciprocal from otherOldUnknown
 6112            if otherOldReciprocal is not None:
 6113                otherOldRev = sourceRenames.get(
 6114                    otherOldReciprocal,
 6115                    otherOldReciprocal
 6116                )
 6117                # Note that the reciprocal is reciprocal, which we don't
 6118                # need to merge
 6119                self.mergeTransitions(
 6120                    fromID,
 6121                    otherOldRev,
 6122                    transition,
 6123                    mergeReciprocal=False
 6124                )
 6125                # Remove it from the renames map
 6126                if otherOldReciprocal in sourceRenames:
 6127                    del sourceRenames[otherOldReciprocal]
 6128
 6129            # Merge any renamed reciprocal onto reciprocal
 6130            if reciprocal in destRenames:
 6131                extraRev = destRenames[reciprocal]
 6132                self.mergeTransitions(
 6133                    connectID,
 6134                    extraRev,
 6135                    reciprocal,
 6136                    mergeReciprocal=False
 6137                )
 6138                # Remove it from the renames map
 6139                del destRenames[reciprocal]
 6140
 6141        # Accumulate new tags & annotations for the transitions
 6142        self.tagTransition(fromID, transition, tags)
 6143        self.annotateTransition(fromID, transition, annotations)
 6144
 6145        if reciprocal is not None:
 6146            self.tagTransition(connectID, reciprocal, revTags)
 6147            self.annotateTransition(connectID, reciprocal, revAnnotations)
 6148
 6149        # Override copied requirement/consequences for the transitions
 6150        if requirement is not None:
 6151            self.setTransitionRequirement(
 6152                fromID,
 6153                transition,
 6154                requirement
 6155            )
 6156        if applyConsequence is not None:
 6157            self.setConsequence(
 6158                fromID,
 6159                transition,
 6160                applyConsequence
 6161            )
 6162
 6163        if reciprocal is not None:
 6164            if revRequires is not None:
 6165                self.setTransitionRequirement(
 6166                    connectID,
 6167                    reciprocal,
 6168                    revRequires
 6169                )
 6170            if revConsequence is not None:
 6171                self.setConsequence(
 6172                    connectID,
 6173                    reciprocal,
 6174                    revConsequence
 6175                )
 6176
 6177        # Remove 'unconfirmed' tag if it was present
 6178        self.untagDecision(connectID, 'unconfirmed')
 6179
 6180        # Final checks
 6181        assert self.getDestination(fromDecision, transition) == connectID
 6182        useConnect: base.AnyDecisionSpecifier
 6183        useRev: Optional[str]
 6184        if connectTo is None:
 6185            useConnect = connectID
 6186        else:
 6187            useConnect = connectTo
 6188        if reciprocal is None:
 6189            useRev = self.getReciprocal(fromDecision, transition)
 6190        else:
 6191            useRev = reciprocal
 6192        if useRev is not None:
 6193            try:
 6194                assert self.getDestination(useConnect, useRev) == fromID
 6195            except AmbiguousDecisionSpecifierError:
 6196                assert self.getDestination(connectID, useRev) == fromID
 6197
 6198        # Return our final rename dictionaries
 6199        return (destRenames, sourceRenames)
 6200
 6201    def endingID(self, name: base.DecisionName) -> base.DecisionID:
 6202        """
 6203        Returns the decision ID for the ending with the specified name.
 6204        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
 6205        don't normally include any zone information. If no ending with
 6206        the specified name already existed, then a new ending with that
 6207        name will be created and its Decision ID will be returned.
 6208
 6209        If a new decision is created, it will be tagged as unconfirmed.
 6210
 6211        Note that endings mostly aren't special: they're normal
 6212        decisions in a separate singular-focalized domain. However, some
 6213        parts of the exploration and journal machinery treat them
 6214        differently (in particular, taking certain actions via
 6215        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
 6216        active is an error.
 6217        """
 6218        # Create our new ending decision if we need to
 6219        try:
 6220            endID = self.resolveDecision(
 6221                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
 6222            )
 6223        except MissingDecisionError:
 6224            # Create a new decision for the ending
 6225            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
 6226            # Tag it as unconfirmed
 6227            self.tagDecision(endID, 'unconfirmed')
 6228
 6229        return endID
 6230
 6231    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
 6232        """
 6233        Given the name of a trigger group, returns the ID of the special
 6234        node representing that trigger group in the `TRIGGERS_DOMAIN`.
 6235        If the specified group didn't already exist, it will be created.
 6236
 6237        Trigger group decisions are not special: they just exist in a
 6238        separate spreading-focalized domain and have a few API methods to
 6239        access them, but all the normal decision-related API methods
 6240        still work. Their intended use is for sets of global triggers,
 6241        by attaching actions with the 'trigger' tag to them and then
 6242        activating or deactivating them as needed.
 6243        """
 6244        result = self.getDecision(
 6245            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 6246        )
 6247        if result is None:
 6248            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
 6249        else:
 6250            return result
 6251
 6252    @staticmethod
 6253    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
 6254        """
 6255        Returns one of a number of example decision graphs, depending on
 6256        the string given. It returns a fresh copy each time. The graphs
 6257        are:
 6258
 6259        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
 6260            and 2, each connected to the next in the sequence by a
 6261            'next' transition with reciprocal 'prev'. In other words, a
 6262            simple little triangle. There are no tags, annotations,
 6263            requirements, consequences, mechanisms, or equivalences.
 6264        - 'abc': A more complicated 3-node setup that introduces a
 6265            little bit of everything. In this graph, we have the same
 6266            three nodes, but different transitions:
 6267
 6268                * From A you can go 'left' to B with reciprocal 'right'.
 6269                * From A you can also go 'up_left' to B with reciprocal
 6270                    'up_right'. These transitions both require the
 6271                    'grate' mechanism (which is at decision A) to be in
 6272                    state 'open'.
 6273                * From A you can go 'down' to C with reciprocal 'up'.
 6274
 6275            (In this graph, B and C are not directly connected to each
 6276            other.)
 6277
 6278            The graph has two level-0 zones 'zoneA' and 'zoneB', along
 6279            with a level-1 zone 'upZone'. Decisions A and C are in
 6280            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
 6281            not.
 6282
 6283            The decision A has annotation:
 6284
 6285                'This is a multi-word "annotation."'
 6286
 6287            The transition 'down' from A has annotation:
 6288
 6289                "Transition 'annotation.'"
 6290
 6291            Decision B has tags 'b' with value 1 and 'tag2' with value
 6292            '"value"'.
 6293
 6294            Decision C has tag 'aw"ful' with value "ha'ha'".
 6295
 6296            Transition 'up' from C has tag 'fast' with value 1.
 6297
 6298            At decision C there are actions 'grab_helmet' and
 6299            'pull_lever'.
 6300
 6301            The 'grab_helmet' transition requires that you don't have
 6302            the 'helmet' capability, and gives you that capability,
 6303            deactivating with delay 3.
 6304
 6305            The 'pull_lever' transition requires that you do have the
 6306            'helmet' capability, and takes away that capability, but it
 6307            also gives you 1 token, and if you have 2 tokens (before
 6308            getting the one extra), it sets the 'grate' mechanism (which
 6309            is a decision A) to state 'open' and deactivates.
 6310
 6311            The graph has an equivalence: having the 'helmet' capability
 6312            satisfies requirements for the 'grate' mechanism to be in the
 6313            'open' state.
 6314
 6315        """
 6316        result = DecisionGraph()
 6317        if which == 'simple':
 6318            result.addDecision('A')  # id 0
 6319            result.addDecision('B')  # id 1
 6320            result.addDecision('C')  # id 2
 6321            result.addTransition('A', 'next', 'B', 'prev')
 6322            result.addTransition('B', 'next', 'C', 'prev')
 6323            result.addTransition('C', 'next', 'A', 'prev')
 6324        elif which == 'abc':
 6325            result.addDecision('A')  # id 0
 6326            result.addDecision('B')  # id 1
 6327            result.addDecision('C')  # id 2
 6328            result.createZone('zoneA', 0)
 6329            result.createZone('zoneB', 0)
 6330            result.createZone('upZone', 1)
 6331            result.addZoneToZone('zoneA', 'upZone')
 6332            result.addDecisionToZone('A', 'zoneA')
 6333            result.addDecisionToZone('B', 'zoneB')
 6334            result.addDecisionToZone('C', 'zoneA')
 6335            result.addTransition('A', 'left', 'B', 'right')
 6336            result.addTransition('A', 'up_left', 'B', 'up_right')
 6337            result.addTransition('A', 'down', 'C', 'up')
 6338            result.setTransitionRequirement(
 6339                'A',
 6340                'up_left',
 6341                base.ReqMechanism('grate', 'open')
 6342            )
 6343            result.setTransitionRequirement(
 6344                'B',
 6345                'up_right',
 6346                base.ReqMechanism('grate', 'open')
 6347            )
 6348            result.annotateDecision('A', 'This is a multi-word "annotation."')
 6349            result.annotateTransition('A', 'down', "Transition 'annotation.'")
 6350            result.tagDecision('B', 'b')
 6351            result.tagDecision('B', 'tag2', '"value"')
 6352            result.tagDecision('C', 'aw"ful', "ha'ha")
 6353            result.tagTransition('C', 'up', 'fast')
 6354            result.addMechanism('grate', 'A')
 6355            result.addAction(
 6356                'C',
 6357                'grab_helmet',
 6358                base.ReqNot(base.ReqCapability('helmet')),
 6359                [
 6360                    base.effect(gain='helmet'),
 6361                    base.effect(deactivate=True, delay=3)
 6362                ]
 6363            )
 6364            result.addAction(
 6365                'C',
 6366                'pull_lever',
 6367                base.ReqCapability('helmet'),
 6368                [
 6369                    base.effect(lose='helmet'),
 6370                    base.effect(gain=('token', 1)),
 6371                    base.condition(
 6372                        base.ReqTokens('token', 2),
 6373                        [
 6374                            base.effect(set=('grate', 'open')),
 6375                            base.effect(deactivate=True)
 6376                        ]
 6377                    )
 6378                ]
 6379            )
 6380            result.addEquivalence(
 6381                base.ReqCapability('helmet'),
 6382                (0, 'open')
 6383            )
 6384        else:
 6385            raise ValueError(f"Invalid example name: {which!r}")
 6386
 6387        return result
 6388
 6389
 6390#---------------------------#
 6391# DiscreteExploration class #
 6392#---------------------------#
 6393
 6394def emptySituation() -> base.Situation:
 6395    """
 6396    Creates and returns an empty situation: A situation that has an
 6397    empty `DecisionGraph`, an empty `State`, a 'pending' decision type
 6398    with `None` as the action taken, no tags, and no annotations.
 6399    """
 6400    return base.Situation(
 6401        graph=DecisionGraph(),
 6402        state=base.emptyState(),
 6403        type='pending',
 6404        action=None,
 6405        saves={},
 6406        tags={},
 6407        annotations=[]
 6408    )
 6409
 6410
 6411class DiscreteExploration:
 6412    """
 6413    A list of `Situations` each of which contains a `DecisionGraph`
 6414    representing exploration over time, with `States` containing
 6415    `FocalContext` information for each step and 'taken' values for the
 6416    transition selected (at a particular decision) in that step. Each
 6417    decision graph represents a new state of the world (and/or new
 6418    knowledge about a persisting state of the world), and the 'taken'
 6419    transition in one situation transition indicates which option was
 6420    selected, or what event happened to cause update(s). Depending on the
 6421    resolution, it could represent a close record of every decision made
 6422    or a more coarse set of snapshots from gameplay with more time in
 6423    between.
 6424
 6425    The steps of the exploration can also be tagged and annotated (see
 6426    `tagStep` and `annotateStep`).
 6427
 6428    When a new `DiscreteExploration` is created, it starts out with an
 6429    empty `Situation` that contains an empty `DecisionGraph`. Use the
 6430    `start` method to name the starting decision point and set things up
 6431    for other methods.
 6432
 6433    Tracking of player goals and destinations is also possible (see the
 6434    `quest`, `progress`, `complete`, `destination`, and `arrive` methods).
 6435    TODO: That
 6436    """
 6437    def __init__(self) -> None:
 6438        self.situations: List[base.Situation] = [
 6439            base.Situation(
 6440                graph=DecisionGraph(),
 6441                state=base.emptyState(),
 6442                type='pending',
 6443                action=None,
 6444                saves={},
 6445                tags={},
 6446                annotations=[]
 6447            )
 6448        ]
 6449
 6450    # Note: not hashable
 6451
 6452    def __eq__(self, other):
 6453        """
 6454        Equality checker. `DiscreteExploration`s can only be equal to
 6455        other `DiscreteExploration`s, not to other kinds of things.
 6456        """
 6457        if not isinstance(other, DiscreteExploration):
 6458            return False
 6459        else:
 6460            return self.situations == other.situations
 6461
 6462    @staticmethod
 6463    def fromGraph(
 6464        graph: DecisionGraph,
 6465        state: Optional[base.State] = None
 6466    ) -> 'DiscreteExploration':
 6467        """
 6468        Creates an exploration which has just a single step whose graph
 6469        is the entire specified graph, with the specified decision as
 6470        the primary decision (if any). The graph is copied, so that
 6471        changes to the exploration will not modify it. A starting state
 6472        may also be specified if desired, although if not an empty state
 6473        will be used (a provided starting state is NOT copied, but used
 6474        directly).
 6475
 6476        Example:
 6477
 6478        >>> g = DecisionGraph()
 6479        >>> g.addDecision('Room1')
 6480        0
 6481        >>> g.addDecision('Room2')
 6482        1
 6483        >>> g.addTransition('Room1', 'door', 'Room2', 'door')
 6484        >>> e = DiscreteExploration.fromGraph(g)
 6485        >>> len(e)
 6486        1
 6487        >>> e.getSituation().graph == g
 6488        True
 6489        >>> e.getActiveDecisions()
 6490        set()
 6491        >>> e.primaryDecision() is None
 6492        True
 6493        >>> e.observe('Room1', 'hatch')
 6494        2
 6495        >>> e.getSituation().graph == g
 6496        False
 6497        >>> e.getSituation().graph.destinationsFrom('Room1')
 6498        {'door': 1, 'hatch': 2}
 6499        >>> g.destinationsFrom('Room1')
 6500        {'door': 1}
 6501        """
 6502        result = DiscreteExploration()
 6503        result.situations[0] = base.Situation(
 6504            graph=copy.deepcopy(graph),
 6505            state=base.emptyState() if state is None else state,
 6506            type='pending',
 6507            action=None,
 6508            saves={},
 6509            tags={},
 6510            annotations=[]
 6511        )
 6512        return result
 6513
 6514    def __len__(self) -> int:
 6515        """
 6516        The 'length' of an exploration is the number of steps.
 6517        """
 6518        return len(self.situations)
 6519
 6520    def __getitem__(self, i: int) -> base.Situation:
 6521        """
 6522        Indexing an exploration returns the situation at that step.
 6523        """
 6524        return self.situations[i]
 6525
 6526    def __iter__(self) -> Iterator[base.Situation]:
 6527        """
 6528        Iterating over an exploration yields each `Situation` in order.
 6529        """
 6530        for i in range(len(self)):
 6531            yield self[i]
 6532
 6533    def getSituation(self, step: int = -1) -> base.Situation:
 6534        """
 6535        Returns a `base.Situation` named tuple detailing the state of
 6536        the exploration at a given step (or at the current step if no
 6537        argument is given). Note that this method works the same
 6538        way as indexing the exploration: see `__getitem__`.
 6539
 6540        Raises an `IndexError` if asked for a step that's out-of-range.
 6541        """
 6542        return self[step]
 6543
 6544    def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]:
 6545        """
 6546        Returns the current primary `base.DecisionID`, or the primary
 6547        decision from a specific step if one is specified. This may be
 6548        `None` for some steps, but mostly it's the destination of the
 6549        transition taken in the previous step.
 6550        """
 6551        return self[step].state['primaryDecision']
 6552
 6553    def effectiveCapabilities(
 6554        self,
 6555        step: int = -1
 6556    ) -> base.CapabilitySet:
 6557        """
 6558        Returns the effective capability set for the specified step
 6559        (default is the last/current step). See
 6560        `base.effectiveCapabilities`.
 6561        """
 6562        return base.effectiveCapabilitySet(self.getSituation(step).state)
 6563
 6564    def getCommonContext(
 6565        self,
 6566        step: Optional[int] = None
 6567    ) -> base.FocalContext:
 6568        """
 6569        Returns the common `FocalContext` at the specified step, or at
 6570        the current step if no argument is given. Raises an `IndexError`
 6571        if an invalid step is specified.
 6572        """
 6573        if step is None:
 6574            step = -1
 6575        state = self.getSituation(step).state
 6576        return state['common']
 6577
 6578    def getActiveContext(
 6579        self,
 6580        step: Optional[int] = None
 6581    ) -> base.FocalContext:
 6582        """
 6583        Returns the active `FocalContext` at the specified step, or at
 6584        the current step if no argument is provided. Raises an
 6585        `IndexError` if an invalid step is specified.
 6586        """
 6587        if step is None:
 6588            step = -1
 6589        state = self.getSituation(step).state
 6590        return state['contexts'][state['activeContext']]
 6591
 6592    def addFocalContext(self, name: base.FocalContextName) -> None:
 6593        """
 6594        Adds a new empty focal context to our set of focal contexts (see
 6595        `emptyFocalContext`). Use `setActiveContext` to swap to it.
 6596        Raises a `FocalContextCollisionError` if the name is already in
 6597        use.
 6598        """
 6599        contextMap = self.getSituation().state['contexts']
 6600        if name in contextMap:
 6601            raise FocalContextCollisionError(
 6602                f"Cannot add focal context {name!r}: a focal context"
 6603                f" with that name already exists."
 6604            )
 6605        contextMap[name] = base.emptyFocalContext()
 6606
 6607    def setActiveContext(self, which: base.FocalContextName) -> None:
 6608        """
 6609        Sets the active context to the named focal context, creating it
 6610        if it did not already exist (makes changes to the current
 6611        situation only). Does not add an exploration step (use
 6612        `advanceSituation` with a 'swap' action for that).
 6613        """
 6614        state = self.getSituation().state
 6615        contextMap = state['contexts']
 6616        if which not in contextMap:
 6617            self.addFocalContext(which)
 6618        state['activeContext'] = which
 6619
 6620    def createDomain(
 6621        self,
 6622        name: base.Domain,
 6623        focalization: base.DomainFocalization = 'singular',
 6624        makeActive: bool = False,
 6625        inCommon: Union[bool, Literal["both"]] = "both"
 6626    ) -> None:
 6627        """
 6628        Creates a new domain with the given focalization type, in either
 6629        the common context (`inCommon` = `True`) the active context
 6630        (`inCommon` = `False`) or both (the default; `inCommon` = 'both').
 6631        The domain's focalization will be set to the given
 6632        `focalization` value (default 'singular') and it will have no
 6633        active decisions. Raises a `DomainCollisionError` if a domain
 6634        with the specified name already exists.
 6635
 6636        Creates the domain in the current situation.
 6637
 6638        If `makeActive` is set to `True` (default is `False`) then the
 6639        domain will be made active in whichever context(s) it's created
 6640        in.
 6641        """
 6642        now = self.getSituation()
 6643        state = now.state
 6644        modify = []
 6645        if inCommon in (True, "both"):
 6646            modify.append(('common', state['common']))
 6647        if inCommon in (False, "both"):
 6648            acName = state['activeContext']
 6649            modify.append(
 6650                ('current ({repr(acName)})', state['contexts'][acName])
 6651            )
 6652
 6653        for (fcType, fc) in modify:
 6654            if name in fc['focalization']:
 6655                raise DomainCollisionError(
 6656                    f"Cannot create domain {repr(name)} because a"
 6657                    f" domain with that name already exists in the"
 6658                    f" {fcType} focal context."
 6659                )
 6660            fc['focalization'][name] = focalization
 6661            if makeActive:
 6662                fc['activeDomains'].add(name)
 6663            if focalization == "spreading":
 6664                fc['activeDecisions'][name] = set()
 6665            elif focalization == "plural":
 6666                fc['activeDecisions'][name] = {}
 6667            else:
 6668                fc['activeDecisions'][name] = None
 6669
 6670    def activateDomain(
 6671        self,
 6672        domain: base.Domain,
 6673        activate: bool = True,
 6674        inContext: base.ContextSpecifier = "active"
 6675    ) -> None:
 6676        """
 6677        Sets the given domain as active (or inactive if 'activate' is
 6678        given as `False`) in the specified context (default "active").
 6679
 6680        Modifies the current situation.
 6681        """
 6682        fc: base.FocalContext
 6683        if inContext == "active":
 6684            fc = self.getActiveContext()
 6685        elif inContext == "common":
 6686            fc = self.getCommonContext()
 6687
 6688        if activate:
 6689            fc['activeDomains'].add(domain)
 6690        else:
 6691            try:
 6692                fc['activeDomains'].remove(domain)
 6693            except KeyError:
 6694                pass
 6695
 6696    def createTriggerGroup(
 6697        self,
 6698        name: base.DecisionName
 6699    ) -> base.DecisionID:
 6700        """
 6701        Creates a new trigger group with the given name, returning the
 6702        decision ID for that trigger group. If this is the first trigger
 6703        group being created, also creates the `TRIGGERS_DOMAIN` domain
 6704        as a spreading-focalized domain that's active in the common
 6705        context (but does NOT set the created trigger group as an active
 6706        decision in that domain).
 6707
 6708        You can use 'goto' effects to activate trigger domains via
 6709        consequences, and 'retreat' effects to deactivate them.
 6710
 6711        Creating a second trigger group with the same name as another
 6712        results in a `ValueError`.
 6713
 6714        TODO: Retreat effects
 6715        """
 6716        ctx = self.getCommonContext()
 6717        if TRIGGERS_DOMAIN not in ctx['focalization']:
 6718            self.createDomain(
 6719                TRIGGERS_DOMAIN,
 6720                focalization='spreading',
 6721                makeActive=True,
 6722                inCommon=True
 6723            )
 6724
 6725        graph = self.getSituation().graph
 6726        if graph.getDecision(
 6727            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 6728        ) is not None:
 6729            raise ValueError(
 6730                f"Cannot create trigger group {name!r}: a trigger group"
 6731                f" with that name already exists."
 6732            )
 6733
 6734        return self.getSituation().graph.triggerGroupID(name)
 6735
 6736    def toggleTriggerGroup(
 6737        self,
 6738        name: base.DecisionName,
 6739        setActive: Union[bool, None] = None
 6740    ):
 6741        """
 6742        Toggles whether the specified trigger group (a decision in the
 6743        `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as
 6744        the `setActive` argument (instead of the default `None`) to set
 6745        the state directly instead of toggling it.
 6746
 6747        Note that trigger groups are decisions in a spreading-focalized
 6748        domain, so they can be activated or deactivated by the 'goto'
 6749        and 'retreat' effects as well.
 6750
 6751        This does not affect whether the `TRIGGERS_DOMAIN` itself is
 6752        active (normally it would always be active).
 6753
 6754        Raises a `MissingDecisionError` if the specified trigger group
 6755        does not exist yet, including when the entire `TRIGGERS_DOMAIN`
 6756        does not exist. Raises a `KeyError` if the target group exists
 6757        but the `TRIGGERS_DOMAIN` has not been set up properly.
 6758        """
 6759        ctx = self.getCommonContext()
 6760        tID = self.getSituation().graph.resolveDecision(
 6761            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
 6762        )
 6763        activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN]
 6764        assert isinstance(activeGroups, set)
 6765        if tID in activeGroups:
 6766            if setActive is not True:
 6767                activeGroups.remove(tID)
 6768        else:
 6769            if setActive is not False:
 6770                activeGroups.add(tID)
 6771
 6772    def getActiveDecisions(
 6773        self,
 6774        step: Optional[int] = None,
 6775        inCommon: Union[bool, Literal["both"]] = "both"
 6776    ) -> Set[base.DecisionID]:
 6777        """
 6778        Returns the set of active decisions at the given step index, or
 6779        at the current step if no step is specified. Raises an
 6780        `IndexError` if the step index is out of bounds (see `__len__`).
 6781        May return an empty set if no decisions are active.
 6782
 6783        If `inCommon` is set to "both" (the default) then decisions
 6784        active in either the common or active context are returned. Set
 6785        it to `True` or `False` to return only decisions active in the
 6786        common (when `True`) or  active (when `False`) context.
 6787        """
 6788        if step is None:
 6789            step = -1
 6790        state = self.getSituation(step).state
 6791        if inCommon == "both":
 6792            return base.combinedDecisionSet(state)
 6793        elif inCommon is True:
 6794            return base.activeDecisionSet(state['common'])
 6795        elif inCommon is False:
 6796            return base.activeDecisionSet(
 6797                state['contexts'][state['activeContext']]
 6798            )
 6799        else:
 6800            raise ValueError(
 6801                f"Invalid inCommon value {repr(inCommon)} (must be"
 6802                f" 'both', True, or False)."
 6803            )
 6804
 6805    def setActiveDecisionsAtStep(
 6806        self,
 6807        step: int,
 6808        domain: base.Domain,
 6809        activate: Union[
 6810            base.DecisionID,
 6811            Dict[base.FocalPointName, Optional[base.DecisionID]],
 6812            Set[base.DecisionID]
 6813        ],
 6814        inCommon: bool = False
 6815    ) -> None:
 6816        """
 6817        Changes the activation status of decisions in the active
 6818        `FocalContext` at the specified step, for the specified domain
 6819        (see `currentActiveContext`). Does this without adding an
 6820        exploration step, which is unusual: normally you should use
 6821        another method like `warp` to update active decisions.
 6822
 6823        Note that this does not change which domains are active, and
 6824        setting active decisions in inactive domains does not make those
 6825        decisions active overall.
 6826
 6827        Which decisions to activate or deactivate are specified as
 6828        either a single `DecisionID`, a list of them, or a set of them,
 6829        depending on the `DomainFocalization` setting in the selected
 6830        `FocalContext` for the specified domain. A `TypeError` will be
 6831        raised if the wrong kind of decision information is provided. If
 6832        the focalization context does not have any focalization value for
 6833        the domain in question, it will be set based on the kind of
 6834        active decision information specified.
 6835
 6836        A `MissingDecisionError` will be raised if a decision is
 6837        included which is not part of the current `DecisionGraph`.
 6838        The provided information will overwrite the previous active
 6839        decision information.
 6840
 6841        If `inCommon` is set to `True`, then decisions are activated or
 6842        deactivated in the common context, instead of in the active
 6843        context.
 6844
 6845        Example:
 6846
 6847        >>> e = DiscreteExploration()
 6848        >>> e.getActiveDecisions()
 6849        set()
 6850        >>> graph = e.getSituation().graph
 6851        >>> graph.addDecision('A')
 6852        0
 6853        >>> graph.addDecision('B')
 6854        1
 6855        >>> graph.addDecision('C')
 6856        2
 6857        >>> e.setActiveDecisionsAtStep(0, 'main', 0)
 6858        >>> e.getActiveDecisions()
 6859        {0}
 6860        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 6861        >>> e.getActiveDecisions()
 6862        {1}
 6863        >>> graph = e.getSituation().graph
 6864        >>> graph.addDecision('One', domain='numbers')
 6865        3
 6866        >>> graph.addDecision('Two', domain='numbers')
 6867        4
 6868        >>> graph.addDecision('Three', domain='numbers')
 6869        5
 6870        >>> graph.addDecision('Bear', domain='animals')
 6871        6
 6872        >>> graph.addDecision('Spider', domain='animals')
 6873        7
 6874        >>> graph.addDecision('Eel', domain='animals')
 6875        8
 6876        >>> ac = e.getActiveContext()
 6877        >>> ac['focalization']['numbers'] = 'plural'
 6878        >>> ac['focalization']['animals'] = 'spreading'
 6879        >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
 6880        >>> ac['activeDecisions']['animals'] = set()
 6881        >>> cc = e.getCommonContext()
 6882        >>> cc['focalization']['numbers'] = 'plural'
 6883        >>> cc['focalization']['animals'] = 'spreading'
 6884        >>> cc['activeDecisions']['numbers'] = {'z': None}
 6885        >>> cc['activeDecisions']['animals'] = set()
 6886        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
 6887        >>> e.getActiveDecisions()
 6888        {1}
 6889        >>> e.activateDomain('numbers')
 6890        >>> e.getActiveDecisions()
 6891        {1, 3}
 6892        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
 6893        >>> e.getActiveDecisions()
 6894        {1, 4}
 6895        >>> # Wrong domain for the decision ID:
 6896        >>> e.setActiveDecisionsAtStep(0, 'main', 3)
 6897        Traceback (most recent call last):
 6898        ...
 6899        ValueError...
 6900        >>> # Wrong domain for one of the decision IDs:
 6901        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
 6902        Traceback (most recent call last):
 6903        ...
 6904        ValueError...
 6905        >>> # Wrong kind of decision information provided.
 6906        >>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
 6907        Traceback (most recent call last):
 6908        ...
 6909        TypeError...
 6910        >>> e.getActiveDecisions()
 6911        {1, 4}
 6912        >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
 6913        >>> e.getActiveDecisions()
 6914        {1, 4}
 6915        >>> e.activateDomain('animals')
 6916        >>> e.getActiveDecisions()
 6917        {1, 4, 6, 7}
 6918        >>> e.setActiveDecisionsAtStep(0, 'animals', {8})
 6919        >>> e.getActiveDecisions()
 6920        {8, 1, 4}
 6921        >>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
 6922        Traceback (most recent call last):
 6923        ...
 6924        IndexError...
 6925        >>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
 6926        Traceback (most recent call last):
 6927        ...
 6928        ValueError...
 6929
 6930        Example of active/common contexts:
 6931
 6932        >>> e = DiscreteExploration()
 6933        >>> graph = e.getSituation().graph
 6934        >>> graph.addDecision('A')
 6935        0
 6936        >>> graph.addDecision('B')
 6937        1
 6938        >>> e.activateDomain('main', inContext="common")
 6939        >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
 6940        >>> e.getActiveDecisions()
 6941        {0}
 6942        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 6943        >>> e.getActiveDecisions()
 6944        {0}
 6945        >>> # (Still active since it's active in the common context)
 6946        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
 6947        >>> e.getActiveDecisions()
 6948        {0, 1}
 6949        >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
 6950        >>> e.getActiveDecisions()
 6951        {1}
 6952        >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
 6953        >>> e.getActiveDecisions()
 6954        {1}
 6955        >>> # (Still active since it's active in the active context)
 6956        >>> e.setActiveDecisionsAtStep(0, 'main', None)
 6957        >>> e.getActiveDecisions()
 6958        set()
 6959        """
 6960        now = self.getSituation(step)
 6961        graph = now.graph
 6962        if inCommon:
 6963            context = self.getCommonContext(step)
 6964        else:
 6965            context = self.getActiveContext(step)
 6966
 6967        defaultFocalization: base.DomainFocalization = 'singular'
 6968        if isinstance(activate, base.DecisionID):
 6969            defaultFocalization = 'singular'
 6970        elif isinstance(activate, dict):
 6971            defaultFocalization = 'plural'
 6972        elif isinstance(activate, set):
 6973            defaultFocalization = 'spreading'
 6974        elif domain not in context['focalization']:
 6975            raise TypeError(
 6976                f"Domain {domain!r} has no focalization in the"
 6977                f" {'common' if inCommon else 'active'} context,"
 6978                f" and the specified position doesn't imply one."
 6979            )
 6980
 6981        focalization = base.getDomainFocalization(
 6982            context,
 6983            domain,
 6984            defaultFocalization
 6985        )
 6986
 6987        # Check domain & existence of decision(s) in question
 6988        if activate is None:
 6989            pass
 6990        elif isinstance(activate, base.DecisionID):
 6991            if activate not in graph:
 6992                raise MissingDecisionError(
 6993                    f"There is no decision {activate} at step {step}."
 6994                )
 6995            if graph.domainFor(activate) != domain:
 6996                raise ValueError(
 6997                    f"Can't set active decisions in domain {domain!r}"
 6998                    f" to decision {graph.identityOf(activate)} because"
 6999                    f" that decision is in actually in domain"
 7000                    f" {graph.domainFor(activate)!r}."
 7001                )
 7002        elif isinstance(activate, dict):
 7003            for fpName, pos in activate.items():
 7004                if pos is None:
 7005                    continue
 7006                if pos not in graph:
 7007                    raise MissingDecisionError(
 7008                        f"There is no decision {pos} at step {step}."
 7009                    )
 7010                if graph.domainFor(pos) != domain:
 7011                    raise ValueError(
 7012                        f"Can't set active decision for focal point"
 7013                        f" {fpName!r} in domain {domain!r}"
 7014                        f" to decision {graph.identityOf(pos)} because"
 7015                        f" that decision is in actually in domain"
 7016                        f" {graph.domainFor(pos)!r}."
 7017                    )
 7018        elif isinstance(activate, set):
 7019            for pos in activate:
 7020                if pos not in graph:
 7021                    raise MissingDecisionError(
 7022                        f"There is no decision {pos} at step {step}."
 7023                    )
 7024                if graph.domainFor(pos) != domain:
 7025                    raise ValueError(
 7026                        f"Can't set {graph.identityOf(pos)} as an"
 7027                        f" active decision in domain {domain!r} to"
 7028                        f" decision because that decision is in"
 7029                        f" actually in domain {graph.domainFor(pos)!r}."
 7030                    )
 7031        else:
 7032            raise TypeError(
 7033                f"Domain {domain!r} has no focalization in the"
 7034                f" {'common' if inCommon else 'active'} context,"
 7035                f" and the specified position doesn't imply one:"
 7036                f"\n{activate!r}"
 7037            )
 7038
 7039        if focalization == 'singular':
 7040            if activate is None or isinstance(activate, base.DecisionID):
 7041                if activate is not None:
 7042                    targetDomain = graph.domainFor(activate)
 7043                    if activate not in graph:
 7044                        raise MissingDecisionError(
 7045                            f"There is no decision {activate} in the"
 7046                            f" graph at step {step}."
 7047                        )
 7048                    elif targetDomain != domain:
 7049                        raise ValueError(
 7050                            f"At step {step}, decision {activate} cannot"
 7051                            f" be the active decision for domain"
 7052                            f" {repr(domain)} because it is in a"
 7053                            f" different domain ({repr(targetDomain)})."
 7054                        )
 7055                context['activeDecisions'][domain] = activate
 7056            else:
 7057                raise TypeError(
 7058                    f"{'Common' if inCommon else 'Active'} focal"
 7059                    f" context at step {step} has {repr(focalization)}"
 7060                    f" focalization for domain {repr(domain)}, so the"
 7061                    f" active decision must be a single decision or"
 7062                    f" None.\n(You provided: {repr(activate)})"
 7063                )
 7064        elif focalization == 'plural':
 7065            if (
 7066                isinstance(activate, dict)
 7067            and all(
 7068                    isinstance(k, base.FocalPointName)
 7069                    for k in activate.keys()
 7070                )
 7071            and all(
 7072                    v is None or isinstance(v, base.DecisionID)
 7073                    for v in activate.values()
 7074                )
 7075            ):
 7076                for v in activate.values():
 7077                    if v is not None:
 7078                        targetDomain = graph.domainFor(v)
 7079                        if v not in graph:
 7080                            raise MissingDecisionError(
 7081                                f"There is no decision {v} in the graph"
 7082                                f" at step {step}."
 7083                            )
 7084                        elif targetDomain != domain:
 7085                            raise ValueError(
 7086                                f"At step {step}, decision {activate}"
 7087                                f" cannot be an active decision for"
 7088                                f" domain {repr(domain)} because it is"
 7089                                f" in a different domain"
 7090                                f" ({repr(targetDomain)})."
 7091                            )
 7092                context['activeDecisions'][domain] = activate
 7093            else:
 7094                raise TypeError(
 7095                    f"{'Common' if inCommon else 'Active'} focal"
 7096                    f" context at step {step} has {repr(focalization)}"
 7097                    f" focalization for domain {repr(domain)}, so the"
 7098                    f" active decision must be a dictionary mapping"
 7099                    f" focal point names to decision IDs (or Nones)."
 7100                    f"\n(You provided: {repr(activate)})"
 7101                )
 7102        elif focalization == 'spreading':
 7103            if (
 7104                isinstance(activate, set)
 7105            and all(isinstance(x, base.DecisionID) for x in activate)
 7106            ):
 7107                for x in activate:
 7108                    targetDomain = graph.domainFor(x)
 7109                    if x not in graph:
 7110                        raise MissingDecisionError(
 7111                            f"There is no decision {x} in the graph"
 7112                            f" at step {step}."
 7113                        )
 7114                    elif targetDomain != domain:
 7115                        raise ValueError(
 7116                            f"At step {step}, decision {activate}"
 7117                            f" cannot be an active decision for"
 7118                            f" domain {repr(domain)} because it is"
 7119                            f" in a different domain"
 7120                            f" ({repr(targetDomain)})."
 7121                        )
 7122                context['activeDecisions'][domain] = activate
 7123            else:
 7124                raise TypeError(
 7125                    f"{'Common' if inCommon else 'Active'} focal"
 7126                    f" context at step {step} has {repr(focalization)}"
 7127                    f" focalization for domain {repr(domain)}, so the"
 7128                    f" active decision must be a set of decision IDs"
 7129                    f"\n(You provided: {repr(activate)})"
 7130                )
 7131        else:
 7132            raise RuntimeError(
 7133                f"Invalid focalization value {repr(focalization)} for"
 7134                f" domain {repr(domain)} at step {step}."
 7135            )
 7136
 7137    def movementAtStep(self, step: int = -1) -> Tuple[
 7138        Union[base.DecisionID, Set[base.DecisionID], None],
 7139        Optional[base.Transition],
 7140        Union[base.DecisionID, Set[base.DecisionID], None]
 7141    ]:
 7142        """
 7143        Given a step number, returns information about the starting
 7144        decision, transition taken, and destination decision for that
 7145        step. Not all steps have all of those, so some items may be
 7146        `None`.
 7147
 7148        For steps where there is no action, where a decision is still
 7149        pending, or where the action type is 'focus', 'swap',
 7150        or 'focalize', the result will be (`None`, `None`, `None`),
 7151        unless a primary decision is available in which case the first
 7152        item in the tuple will be that decision. For 'start' actions, the
 7153        starting position and transition will be `None` (again unless the
 7154        step had a primary decision) but the destination will be the ID
 7155        of the node started at.
 7156
 7157        Also, if the action taken has multiple potential or actual start
 7158        or end points, these may be sets of decision IDs instead of
 7159        single IDs.
 7160
 7161        Note that the primary decision of the starting state is usually
 7162        used as the from-decision, but in some cases an action dictates
 7163        taking a transition from a different decision, and this function
 7164        will return that decision as the from-decision.
 7165
 7166        TODO: Examples!
 7167
 7168        TODO: Account for bounce/follow/goto effects!!!
 7169        """
 7170        now = self.getSituation(step)
 7171        action = now.action
 7172        graph = now.graph
 7173        primary = now.state['primaryDecision']
 7174
 7175        if action is None:
 7176            return (primary, None, None)
 7177
 7178        aType = action[0]
 7179        fromID: Optional[base.DecisionID]
 7180        destID: Optional[base.DecisionID]
 7181        transition: base.Transition
 7182        outcomes: List[bool]
 7183
 7184        if aType in ('noAction', 'focus', 'swap', 'focalize'):
 7185            return (primary, None, None)
 7186        elif aType == 'start':
 7187            assert len(action) == 7
 7188            where = cast(
 7189                Union[
 7190                    base.DecisionID,
 7191                    Dict[base.FocalPointName, base.DecisionID],
 7192                    Set[base.DecisionID]
 7193                ],
 7194                action[1]
 7195            )
 7196            if isinstance(where, dict):
 7197                where = set(where.values())
 7198            return (primary, None, where)
 7199        elif aType in ('take', 'explore'):
 7200            if (
 7201                (len(action) == 4 or len(action) == 7)
 7202            and isinstance(action[2], base.DecisionID)
 7203            ):
 7204                fromID = action[2]
 7205                assert isinstance(action[3], tuple)
 7206                transition, outcomes = action[3]
 7207                if (
 7208                    action[0] == "explore"
 7209                and isinstance(action[4], base.DecisionID)
 7210                ):
 7211                    destID = action[4]
 7212                else:
 7213                    destID = graph.getDestination(fromID, transition)
 7214                return (fromID, transition, destID)
 7215            elif (
 7216                (len(action) == 3 or len(action) == 6)
 7217            and isinstance(action[1], tuple)
 7218            and isinstance(action[2], base.Transition)
 7219            and len(action[1]) == 3
 7220            and action[1][0] in get_args(base.ContextSpecifier)
 7221            and isinstance(action[1][1], base.Domain)
 7222            and isinstance(action[1][2], base.FocalPointName)
 7223            ):
 7224                fromID = base.resolvePosition(now, action[1])
 7225                if fromID is None:
 7226                    raise InvalidActionError(
 7227                        f"{aType!r} action at step {step} has position"
 7228                        f" {action[1]!r} which cannot be resolved to a"
 7229                        f" decision."
 7230                    )
 7231                transition, outcomes = action[2]
 7232                if (
 7233                    action[0] == "explore"
 7234                and isinstance(action[3], base.DecisionID)
 7235                ):
 7236                    destID = action[3]
 7237                else:
 7238                    destID = graph.getDestination(fromID, transition)
 7239                return (fromID, transition, destID)
 7240            else:
 7241                raise InvalidActionError(
 7242                    f"Malformed {aType!r} action:\n{repr(action)}"
 7243                )
 7244        elif aType == 'warp':
 7245            if len(action) != 3:
 7246                raise InvalidActionError(
 7247                    f"Malformed 'warp' action:\n{repr(action)}"
 7248                )
 7249            dest = action[2]
 7250            assert isinstance(dest, base.DecisionID)
 7251            if action[1] in get_args(base.ContextSpecifier):
 7252                # Unspecified starting point; find active decisions in
 7253                # same domain if primary is None
 7254                if primary is not None:
 7255                    return (primary, None, dest)
 7256                else:
 7257                    toDomain = now.graph.domainFor(dest)
 7258                    # TODO: Could check destination focalization here...
 7259                    active = self.getActiveDecisions(step)
 7260                    sameDomain = set(
 7261                        dID
 7262                        for dID in active
 7263                        if now.graph.domainFor(dID) == toDomain
 7264                    )
 7265                    if len(sameDomain) == 1:
 7266                        return (
 7267                            list(sameDomain)[0],
 7268                            None,
 7269                            dest
 7270                        )
 7271                    else:
 7272                        return (
 7273                            sameDomain,
 7274                            None,
 7275                            dest
 7276                        )
 7277            else:
 7278                if (
 7279                    not isinstance(action[1], tuple)
 7280                or not len(action[1]) == 3
 7281                or not action[1][0] in get_args(base.ContextSpecifier)
 7282                or not isinstance(action[1][1], base.Domain)
 7283                or not isinstance(action[1][2], base.FocalPointName)
 7284                ):
 7285                    raise InvalidActionError(
 7286                        f"Malformed 'warp' action:\n{repr(action)}"
 7287                    )
 7288                return (
 7289                    base.resolvePosition(now, action[1]),
 7290                    None,
 7291                    dest
 7292                )
 7293        else:
 7294            raise InvalidActionError(
 7295                f"Action taken had invalid action type {repr(aType)}:"
 7296                f"\n{repr(action)}"
 7297            )
 7298
 7299    def tagStep(
 7300        self,
 7301        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
 7302        tagValue: Union[
 7303            base.TagValue,
 7304            type[base.NoTagValue]
 7305        ] = base.NoTagValue,
 7306        step: int = -1
 7307    ) -> None:
 7308        """
 7309        Adds a tag (or multiple tags) to the current step, or to a
 7310        specific step if `n` is given as an integer rather than the
 7311        default `None`. A tag value should be supplied when a tag is
 7312        given (unless you want to use the default of `1`), but it's a
 7313        `ValueError` to supply a tag value when a dictionary of tags to
 7314        update is provided.
 7315        """
 7316        if isinstance(tagOrTags, base.Tag):
 7317            if tagValue is base.NoTagValue:
 7318                tagValue = 1
 7319
 7320            # Not sure why this is necessary...
 7321            tagValue = cast(base.TagValue, tagValue)
 7322
 7323            self.getSituation(step).tags.update({tagOrTags: tagValue})
 7324        else:
 7325            self.getSituation(step).tags.update(tagOrTags)
 7326
 7327    def annotateStep(
 7328        self,
 7329        annotationOrAnnotations: Union[
 7330            base.Annotation,
 7331            Sequence[base.Annotation]
 7332        ],
 7333        step: Optional[int] = None
 7334    ) -> None:
 7335        """
 7336        Adds an annotation to the current exploration step, or to a
 7337        specific step if `n` is given as an integer rather than the
 7338        default `None`.
 7339        """
 7340        if step is None:
 7341            step = -1
 7342        if isinstance(annotationOrAnnotations, base.Annotation):
 7343            self.getSituation(step).annotations.append(
 7344                annotationOrAnnotations
 7345            )
 7346        else:
 7347            self.getSituation(step).annotations.extend(
 7348                annotationOrAnnotations
 7349            )
 7350
 7351    def hasCapability(
 7352        self,
 7353        capability: base.Capability,
 7354        step: Optional[int] = None,
 7355        inCommon: Union[bool, Literal['both']] = "both"
 7356    ) -> bool:
 7357        """
 7358        Returns True if the player currently had the specified
 7359        capability, at the specified exploration step, and False
 7360        otherwise. Checks the current state if no step is given. Does
 7361        NOT return true if the game state means that the player has an
 7362        equivalent for that capability (see
 7363        `hasCapabilityOrEquivalent`).
 7364
 7365        Normally, `inCommon` is set to 'both' by default and so if
 7366        either the common `FocalContext` or the active one has the
 7367        capability, this will return `True`. `inCommon` may instead be
 7368        set to `True` or `False` to ask about just the common (or
 7369        active) focal context.
 7370        """
 7371        state = self.getSituation().state
 7372        commonCapabilities = state['common']['capabilities']\
 7373            ['capabilities']  # noqa
 7374        activeCapabilities = state['contexts'][state['activeContext']]\
 7375            ['capabilities']['capabilities']  # noqa
 7376
 7377        if inCommon == 'both':
 7378            return (
 7379                capability in commonCapabilities
 7380             or capability in activeCapabilities
 7381            )
 7382        elif inCommon is True:
 7383            return capability in commonCapabilities
 7384        elif inCommon is False:
 7385            return capability in activeCapabilities
 7386        else:
 7387            raise ValueError(
 7388                f"Invalid inCommon value (must be False, True, or"
 7389                f" 'both'; got {repr(inCommon)})."
 7390            )
 7391
 7392    def hasCapabilityOrEquivalent(
 7393        self,
 7394        capability: base.Capability,
 7395        step: Optional[int] = None,
 7396        location: Optional[Set[base.DecisionID]] = None
 7397    ) -> bool:
 7398        """
 7399        Works like `hasCapability`, but also returns `True` if the
 7400        player counts as having the specified capability via an equivalence
 7401        that's part of the current graph. As with `hasCapability`, the
 7402        optional `step` argument is used to specify which step to check,
 7403        with the current step being used as the default.
 7404
 7405        The `location` set can specify where to start looking for
 7406        mechanisms; if left unspecified active decisions for that step
 7407        will be used.
 7408        """
 7409        if step is None:
 7410            step = -1
 7411        if location is None:
 7412            location = self.getActiveDecisions(step)
 7413        situation = self.getSituation(step)
 7414        return base.hasCapabilityOrEquivalent(
 7415            capability,
 7416            base.RequirementContext(
 7417                state=situation.state,
 7418                graph=situation.graph,
 7419                searchFrom=location
 7420            )
 7421        )
 7422
 7423    def gainCapabilityNow(
 7424        self,
 7425        capability: base.Capability,
 7426        inCommon: bool = False
 7427    ) -> None:
 7428        """
 7429        Modifies the current game state to add the specified `Capability`
 7430        to the player's capabilities. No changes are made to the current
 7431        graph.
 7432
 7433        If `inCommon` is set to `True` (default is `False`) then the
 7434        capability will be added to the common `FocalContext` and will
 7435        therefore persist even when a focal context switch happens.
 7436        Normally, it will be added to the currently-active focal
 7437        context.
 7438        """
 7439        state = self.getSituation().state
 7440        if inCommon:
 7441            context = state['common']
 7442        else:
 7443            context = state['contexts'][state['activeContext']]
 7444        context['capabilities']['capabilities'].add(capability)
 7445
 7446    def loseCapabilityNow(
 7447        self,
 7448        capability: base.Capability,
 7449        inCommon: Union[bool, Literal['both']] = "both"
 7450    ) -> None:
 7451        """
 7452        Modifies the current game state to remove the specified `Capability`
 7453        from the player's capabilities. Does nothing if the player
 7454        doesn't already have that capability.
 7455
 7456        By default, this removes the capability from both the common
 7457        capabilities set and the active `FocalContext`'s capabilities
 7458        set, so that afterwards the player will definitely not have that
 7459        capability. However, if you set `inCommon` to either `True` or
 7460        `False`, it will remove the capability from just the common
 7461        capabilities set (if `True`) or just the active capabilities set
 7462        (if `False`). In these cases, removing the capability from just
 7463        one capability set will not actually remove it in terms of the
 7464        `hasCapability` result if it had been present in the other set.
 7465        Set `inCommon` to "both" to use the default behavior explicitly.
 7466        """
 7467        now = self.getSituation()
 7468        if inCommon in ("both", True):
 7469            context = now.state['common']
 7470            try:
 7471                context['capabilities']['capabilities'].remove(capability)
 7472            except KeyError:
 7473                pass
 7474        elif inCommon in ("both", False):
 7475            context = now.state['contexts'][now.state['activeContext']]
 7476            try:
 7477                context['capabilities']['capabilities'].remove(capability)
 7478            except KeyError:
 7479                pass
 7480        else:
 7481            raise ValueError(
 7482                f"Invalid inCommon value (must be False, True, or"
 7483                f" 'both'; got {repr(inCommon)})."
 7484            )
 7485
 7486    def tokenCountNow(self, tokenType: base.Token) -> Optional[int]:
 7487        """
 7488        Returns the number of tokens the player currently has of a given
 7489        type. Returns `None` if the player has never acquired or lost
 7490        tokens of that type.
 7491
 7492        This method adds together tokens from the common and active
 7493        focal contexts.
 7494        """
 7495        state = self.getSituation().state
 7496        commonContext = state['common']
 7497        activeContext = state['contexts'][state['activeContext']]
 7498        base = commonContext['capabilities']['tokens'].get(tokenType)
 7499        if base is None:
 7500            return activeContext['capabilities']['tokens'].get(tokenType)
 7501        else:
 7502            return base + activeContext['capabilities']['tokens'].get(
 7503                tokenType,
 7504                0
 7505            )
 7506
 7507    def adjustTokensNow(
 7508        self,
 7509        tokenType: base.Token,
 7510        amount: int,
 7511        inCommon: bool = False
 7512    ) -> None:
 7513        """
 7514        Modifies the current game state to add the specified number of
 7515        `Token`s of the given type to the player's tokens. No changes are
 7516        made to the current graph. Reduce the number of tokens by
 7517        supplying a negative amount; note that negative token amounts
 7518        are possible.
 7519
 7520        By default, the number of tokens for the current active
 7521        `FocalContext` will be adjusted. However, if `inCommon` is set
 7522        to `True`, then the number of tokens for the common context will
 7523        be adjusted instead.
 7524        """
 7525        # TODO: Custom token caps!
 7526        state = self.getSituation().state
 7527        if inCommon:
 7528            context = state['common']
 7529        else:
 7530            context = state['contexts'][state['activeContext']]
 7531        tokens = context['capabilities']['tokens']
 7532        tokens[tokenType] = tokens.get(tokenType, 0) + amount
 7533
 7534    def setTokensNow(
 7535        self,
 7536        tokenType: base.Token,
 7537        amount: int,
 7538        inCommon: bool = False
 7539    ) -> None:
 7540        """
 7541        Modifies the current game state to set number of `Token`s of the
 7542        given type to a specific amount, regardless of the old value. No
 7543        changes are made to the current graph.
 7544
 7545        By default this sets the number of tokens for the active
 7546        `FocalContext`. But if you set `inCommon` to `True`, it will
 7547        set the number of tokens in the common context instead.
 7548        """
 7549        # TODO: Custom token caps!
 7550        state = self.getSituation().state
 7551        if inCommon:
 7552            context = state['common']
 7553        else:
 7554            context = state['contexts'][state['activeContext']]
 7555        context['capabilities']['tokens'][tokenType] = amount
 7556
 7557    def lookupMechanism(
 7558        self,
 7559        mechanism: base.MechanismName,
 7560        step: Optional[int] = None,
 7561        where: Union[
 7562            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 7563            Collection[base.AnyDecisionSpecifier],
 7564            None
 7565        ] = None
 7566    ) -> base.MechanismID:
 7567        """
 7568        Looks up a mechanism ID by name, in the graph for the specified
 7569        step. The `where` argument specifies where to start looking,
 7570        which helps disambiguate. It can be a tuple with a decision
 7571        specifier and `None` to start from a single decision, or with a
 7572        decision specifier and a transition name to start from either
 7573        end of that transition. It can also be `None` to look at global
 7574        mechanisms and then all decisions directly, although this
 7575        increases the chance of a `MechanismCollisionError`. Finally, it
 7576        can be some other non-tuple collection of decision specifiers to
 7577        start from that set.
 7578
 7579        If no step is specified, uses the current step.
 7580        """
 7581        if step is None:
 7582            step = -1
 7583        situation = self.getSituation(step)
 7584        graph = situation.graph
 7585        searchFrom: Collection[base.AnyDecisionSpecifier]
 7586        if where is None:
 7587            searchFrom = set()
 7588        elif isinstance(where, tuple):
 7589            if len(where) != 2:
 7590                raise ValueError(
 7591                    f"Mechanism lookup location was a tuple with an"
 7592                    f" invalid length (must be length-2 if it's a"
 7593                    f" tuple):\n  {repr(where)}"
 7594                )
 7595            where = cast(
 7596                Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
 7597                where
 7598            )
 7599            if where[1] is None:
 7600                searchFrom = {graph.resolveDecision(where[0])}
 7601            else:
 7602                searchFrom = graph.bothEnds(where[0], where[1])
 7603        else:  # must be a collection of specifiers
 7604            searchFrom = cast(Collection[base.AnyDecisionSpecifier], where)
 7605        return graph.lookupMechanism(searchFrom, mechanism)
 7606
 7607    def mechanismState(
 7608        self,
 7609        mechanism: base.AnyMechanismSpecifier,
 7610        where: Optional[Set[base.DecisionID]] = None,
 7611        step: int = -1
 7612    ) -> Optional[base.MechanismState]:
 7613        """
 7614        Returns the current state for the specified mechanism (or the
 7615        state at the specified step if a step index is given). `where`
 7616        may be provided as a set of decision IDs to indicate where to
 7617        search for the named mechanism, or a mechanism ID may be provided
 7618        in the first place. Mechanism states are properties of a `State`
 7619        but are not associated with focal contexts.
 7620        """
 7621        situation = self.getSituation(step)
 7622        mID = situation.graph.resolveMechanism(mechanism, startFrom=where)
 7623        return situation.state['mechanisms'].get(
 7624            mID,
 7625            base.DEFAULT_MECHANISM_STATE
 7626        )
 7627
 7628    def setMechanismStateNow(
 7629        self,
 7630        mechanism: base.AnyMechanismSpecifier,
 7631        toState: base.MechanismState,
 7632        where: Optional[Set[base.DecisionID]] = None
 7633    ) -> None:
 7634        """
 7635        Sets the state of the specified mechanism to the specified
 7636        state. Mechanisms can only be in one state at once, so this
 7637        removes any previous states for that mechanism (note that via
 7638        equivalences multiple mechanism states can count as active).
 7639
 7640        The mechanism can be any kind of mechanism specifier (see
 7641        `base.AnyMechanismSpecifier`). If it's not a mechanism ID and
 7642        doesn't have its own position information, the 'where' argument
 7643        can be used to hint where to search for the mechanism.
 7644        """
 7645        now = self.getSituation()
 7646        mID = now.graph.resolveMechanism(mechanism, startFrom=where)
 7647        if mID is None:
 7648            raise MissingMechanismError(
 7649                f"Couldn't find mechanism for {repr(mechanism)}."
 7650            )
 7651        now.state['mechanisms'][mID] = toState
 7652
 7653    def skillLevel(
 7654        self,
 7655        skill: base.Skill,
 7656        step: Optional[int] = None
 7657    ) -> Optional[base.Level]:
 7658        """
 7659        Returns the skill level the player had in a given skill at a
 7660        given step, or for the current step if no step is specified.
 7661        Returns `None` if the player had never acquired or lost levels
 7662        in that skill before the specified step (skill level would count
 7663        as 0 in that case).
 7664
 7665        This method adds together levels from the common and active
 7666        focal contexts.
 7667        """
 7668        if step is None:
 7669            step = -1
 7670        state = self.getSituation(step).state
 7671        commonContext = state['common']
 7672        activeContext = state['contexts'][state['activeContext']]
 7673        base = commonContext['capabilities']['skills'].get(skill)
 7674        if base is None:
 7675            return activeContext['capabilities']['skills'].get(skill)
 7676        else:
 7677            return base + activeContext['capabilities']['skills'].get(
 7678                skill,
 7679                0
 7680            )
 7681
 7682    def adjustSkillLevelNow(
 7683        self,
 7684        skill: base.Skill,
 7685        levels: base.Level,
 7686        inCommon: bool = False
 7687    ) -> None:
 7688        """
 7689        Modifies the current game state to add the specified number of
 7690        `Level`s of the given skill. No changes are made to the current
 7691        graph. Reduce the skill level by supplying negative levels; note
 7692        that negative skill levels are possible.
 7693
 7694        By default, the skill level for the current active
 7695        `FocalContext` will be adjusted. However, if `inCommon` is set
 7696        to `True`, then the skill level for the common context will be
 7697        adjusted instead.
 7698        """
 7699        # TODO: Custom level caps?
 7700        state = self.getSituation().state
 7701        if inCommon:
 7702            context = state['common']
 7703        else:
 7704            context = state['contexts'][state['activeContext']]
 7705        skills = context['capabilities']['skills']
 7706        skills[skill] = skills.get(skill, 0) + levels
 7707
 7708    def setSkillLevelNow(
 7709        self,
 7710        skill: base.Skill,
 7711        level: base.Level,
 7712        inCommon: bool = False
 7713    ) -> None:
 7714        """
 7715        Modifies the current game state to set `Skill` `Level` for the
 7716        given skill, regardless of the old value. No changes are made to
 7717        the current graph.
 7718
 7719        By default this sets the skill level for the active
 7720        `FocalContext`. But if you set `inCommon` to `True`, it will set
 7721        the skill level in the common context instead.
 7722        """
 7723        # TODO: Custom level caps?
 7724        state = self.getSituation().state
 7725        if inCommon:
 7726            context = state['common']
 7727        else:
 7728            context = state['contexts'][state['activeContext']]
 7729        skills = context['capabilities']['skills']
 7730        skills[skill] = level
 7731
 7732    def updateRequirementNow(
 7733        self,
 7734        decision: base.AnyDecisionSpecifier,
 7735        transition: base.Transition,
 7736        requirement: Optional[base.Requirement]
 7737    ) -> None:
 7738        """
 7739        Updates the requirement for a specific transition in a specific
 7740        decision. Use `None` to remove the requirement for that edge.
 7741        """
 7742        if requirement is None:
 7743            requirement = base.ReqNothing()
 7744        self.getSituation().graph.setTransitionRequirement(
 7745            decision,
 7746            transition,
 7747            requirement
 7748        )
 7749
 7750    def isTraversable(
 7751        self,
 7752        decision: base.AnyDecisionSpecifier,
 7753        transition: base.Transition,
 7754        step: int = -1
 7755    ) -> bool:
 7756        """
 7757        Returns True if the specified transition from the specified
 7758        decision had its requirement satisfied by the game state at the
 7759        specified step (or at the current step if no step is specified).
 7760        Raises an `IndexError` if the specified step doesn't exist, and
 7761        a `KeyError` if the decision or transition specified does not
 7762        exist in the `DecisionGraph` at that step.
 7763        """
 7764        situation = self.getSituation(step)
 7765        req = situation.graph.getTransitionRequirement(decision, transition)
 7766        ctx = base.contextForTransition(situation, decision, transition)
 7767        fromID = situation.graph.resolveDecision(decision)
 7768        return (
 7769            req.satisfied(ctx)
 7770        and (fromID, transition) not in situation.state['deactivated']
 7771        )
 7772
 7773    def applyTransitionEffect(
 7774        self,
 7775        whichEffect: base.EffectSpecifier,
 7776        moveWhich: Optional[base.FocalPointName] = None
 7777    ) -> Optional[base.DecisionID]:
 7778        """
 7779        Applies an effect attached to a transition, taking charges and
 7780        delay into account based on the current `Situation`.
 7781        Modifies the effect's trigger count (but may not actually
 7782        trigger the effect if the charges and/or delay values indicate
 7783        not to; see `base.doTriggerEffect`).
 7784
 7785        If a specific focal point in a plural-focalized domain is
 7786        triggering the effect, the focal point name should be specified
 7787        via `moveWhich` so that goto `Effect`s can know which focal
 7788        point to move when it's not explicitly specified in the effect.
 7789        TODO: Test this!
 7790
 7791        Returns None most of the time, but if a 'goto', 'bounce', or
 7792        'follow' effect was applied, it returns the decision ID for that
 7793        effect's destination, which would override a transition's normal
 7794        destination. If it returns a destination ID, then the exploration
 7795        state will already have been updated to set the position there,
 7796        and further position updates are not needed.
 7797
 7798        Note that transition effects which update active decisions will
 7799        also update the exploration status of those decisions to
 7800        'exploring' if they had been in an unvisited status (see
 7801        `updatePosition` and `hasBeenVisited`).
 7802
 7803        Note: callers should immediately update situation-based variables
 7804        that might have been changes by a 'revert' effect.
 7805        """
 7806        now = self.getSituation()
 7807        effect, triggerCount = base.doTriggerEffect(now, whichEffect)
 7808        if triggerCount is not None:
 7809            return self.applyExtraneousEffect(
 7810                effect,
 7811                where=whichEffect[:2],
 7812                moveWhich=moveWhich
 7813            )
 7814        else:
 7815            return None
 7816
 7817    def applyExtraneousEffect(
 7818        self,
 7819        effect: base.Effect,
 7820        where: Optional[
 7821            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 7822        ] = None,
 7823        moveWhich: Optional[base.FocalPointName] = None,
 7824        challengePolicy: base.ChallengePolicy = "specified"
 7825    ) -> Optional[base.DecisionID]:
 7826        """
 7827        Applies a single extraneous effect to the state & graph,
 7828        *without* accounting for charges or delay values, since the
 7829        effect is not part of the graph (use `applyTransitionEffect` to
 7830        apply effects that are attached to transitions, which is almost
 7831        always the function you should be using). An associated
 7832        transition for the extraneous effect can be supplied using the
 7833        `where` argument, and effects like 'deactivate' and 'edit' will
 7834        affect it (but the effect's charges and delay values will still
 7835        be ignored).
 7836
 7837        If the effect would change the destination of a transition, the
 7838        altered destination ID is returned: 'bounce' effects return the
 7839        provided decision part of `where`, 'goto' effects return their
 7840        target, and 'follow' effects return the destination followed to
 7841        (possibly via chained follows in the extreme case). In all other
 7842        cases, `None` is returned indicating no change to a normal
 7843        destination.
 7844
 7845        If a specific focal point in a plural-focalized domain is
 7846        triggering the effect, the focal point name should be specified
 7847        via `moveWhich` so that goto `Effect`s can know which focal
 7848        point to move when it's not explicitly specified in the effect.
 7849        TODO: Test this!
 7850
 7851        Note that transition effects which update active decisions will
 7852        also update the exploration status of those decisions to
 7853        'exploring' if they had been in an unvisited status and will
 7854        remove any 'unconfirmed' tag they might still have (see
 7855        `updatePosition` and `hasBeenVisited`).
 7856
 7857        The given `challengePolicy` is applied when traversing further
 7858        transitions due to 'follow' effects.
 7859
 7860        Note: Anyone calling `applyExtraneousEffect` should update any
 7861        situation-based variables immediately after the call, as a
 7862        'revert' effect may have changed the current graph and/or state.
 7863        """
 7864        typ = effect['type']
 7865        value = effect['value']
 7866        applyTo = effect['applyTo']
 7867        inCommon = applyTo == 'common'
 7868
 7869        now = self.getSituation()
 7870
 7871        if where is not None:
 7872            if where[1] is not None:
 7873                searchFrom = now.graph.bothEnds(where[0], where[1])
 7874            else:
 7875                searchFrom = {now.graph.resolveDecision(where[0])}
 7876        else:
 7877            searchFrom = None
 7878
 7879        # Note: Delay and charges are ignored!
 7880
 7881        if typ in ("gain", "lose"):
 7882            value = cast(
 7883                Union[
 7884                    base.Capability,
 7885                    Tuple[base.Token, base.TokenCount],
 7886                    Tuple[Literal['skill'], base.Skill, base.Level],
 7887                ],
 7888                value
 7889            )
 7890            if isinstance(value, base.Capability):
 7891                if typ == "gain":
 7892                    self.gainCapabilityNow(value, inCommon)
 7893                else:
 7894                    self.loseCapabilityNow(value, inCommon)
 7895            elif len(value) == 2:  # must be a token, amount pair
 7896                token, amount = cast(
 7897                    Tuple[base.Token, base.TokenCount],
 7898                    value
 7899                )
 7900                if typ == "lose":
 7901                    amount *= -1
 7902                self.adjustTokensNow(token, amount, inCommon)
 7903            else:  # must be a 'skill', skill, level triple
 7904                _, skill, levels = cast(
 7905                    Tuple[Literal['skill'], base.Skill, base.Level],
 7906                    value
 7907                )
 7908                if typ == "lose":
 7909                    levels *= -1
 7910                self.adjustSkillLevelNow(skill, levels, inCommon)
 7911
 7912        elif typ == "set":
 7913            value = cast(
 7914                Union[
 7915                    Tuple[base.Token, base.TokenCount],
 7916                    Tuple[base.AnyMechanismSpecifier, base.MechanismState],
 7917                    Tuple[Literal['skill'], base.Skill, base.Level],
 7918                ],
 7919                value
 7920            )
 7921            if len(value) == 2:  # must be a token or mechanism pair
 7922                if isinstance(value[1], base.TokenCount):  # token
 7923                    token, amount = cast(
 7924                        Tuple[base.Token, base.TokenCount],
 7925                        value
 7926                    )
 7927                    self.setTokensNow(token, amount, inCommon)
 7928                else: # mechanism
 7929                    mechanism, state = cast(
 7930                        Tuple[
 7931                            base.AnyMechanismSpecifier,
 7932                            base.MechanismState
 7933                        ],
 7934                        value
 7935                    )
 7936                    self.setMechanismStateNow(mechanism, state, searchFrom)
 7937            else:  # must be a 'skill', skill, level triple
 7938                _, skill, level = cast(
 7939                    Tuple[Literal['skill'], base.Skill, base.Level],
 7940                    value
 7941                )
 7942                self.setSkillLevelNow(skill, level, inCommon)
 7943
 7944        elif typ == "toggle":
 7945            # Length-1 list just toggles a capability on/off based on current
 7946            # state (not attending to equivalents):
 7947            if isinstance(value, List):  # capabilities list
 7948                value = cast(List[base.Capability], value)
 7949                if len(value) == 0:
 7950                    raise ValueError(
 7951                        "Toggle effect has empty capabilities list."
 7952                    )
 7953                if len(value) == 1:
 7954                    capability = value[0]
 7955                    if self.hasCapability(capability, inCommon=False):
 7956                        self.loseCapabilityNow(capability, inCommon=False)
 7957                    else:
 7958                        self.gainCapabilityNow(capability)
 7959                else:
 7960                    # Otherwise toggle all powers off, then one on,
 7961                    # based on the first capability that's currently on.
 7962                    # Note we do NOT count equivalences.
 7963
 7964                    # Find first capability that's on:
 7965                    firstIndex: Optional[int] = None
 7966                    for i, capability in enumerate(value):
 7967                        if self.hasCapability(capability):
 7968                            firstIndex = i
 7969                            break
 7970
 7971                    # Turn them all off:
 7972                    for capability in value:
 7973                        self.loseCapabilityNow(capability, inCommon=False)
 7974                        # TODO: inCommon for the check?
 7975
 7976                    if firstIndex is None:
 7977                        self.gainCapabilityNow(value[0])
 7978                    else:
 7979                        self.gainCapabilityNow(
 7980                            value[(firstIndex + 1) % len(value)]
 7981                        )
 7982            else:  # must be a mechanism w/ states list
 7983                mechanism, states = cast(
 7984                    Tuple[
 7985                        base.AnyMechanismSpecifier,
 7986                        List[base.MechanismState]
 7987                    ],
 7988                    value
 7989                )
 7990                currentState = self.mechanismState(mechanism, where=searchFrom)
 7991                if len(states) == 1:
 7992                    if currentState == states[0]:
 7993                        # default alternate state
 7994                        self.setMechanismStateNow(
 7995                            mechanism,
 7996                            base.DEFAULT_MECHANISM_STATE,
 7997                            searchFrom
 7998                        )
 7999                    else:
 8000                        self.setMechanismStateNow(
 8001                            mechanism,
 8002                            states[0],
 8003                            searchFrom
 8004                        )
 8005                else:
 8006                    # Find our position in the list, if any
 8007                    try:
 8008                        currentIndex = states.index(cast(str, currentState))
 8009                        # Cast here just because we know that None will
 8010                        # raise a ValueError but we'll catch it, and we
 8011                        # want to suppress the mypy warning about the
 8012                        # option
 8013                    except ValueError:
 8014                        currentIndex = len(states) - 1
 8015                    # Set next state in list as current state
 8016                    nextIndex = (currentIndex + 1) % len(states)
 8017                    self.setMechanismStateNow(
 8018                        mechanism,
 8019                        states[nextIndex],
 8020                        searchFrom
 8021                    )
 8022
 8023        elif typ == "deactivate":
 8024            if where is None or where[1] is None:
 8025                raise ValueError(
 8026                    "Can't apply a deactivate effect without specifying"
 8027                    " which transition it applies to."
 8028                )
 8029
 8030            decision, transition = cast(
 8031                Tuple[base.AnyDecisionSpecifier, base.Transition],
 8032                where
 8033            )
 8034
 8035            dID = now.graph.resolveDecision(decision)
 8036            now.state['deactivated'].add((dID, transition))
 8037
 8038        elif typ == "edit":
 8039            value = cast(List[List[commands.Command]], value)
 8040            # If there are no blocks, do nothing
 8041            if len(value) > 0:
 8042                # Apply the first block of commands and then rotate the list
 8043                scope: commands.Scope = {}
 8044                if where is not None:
 8045                    here: base.DecisionID = now.graph.resolveDecision(
 8046                        where[0]
 8047                    )
 8048                    outwards: Optional[base.Transition] = where[1]
 8049                    scope['@'] = here
 8050                    scope['@t'] = outwards
 8051                    if outwards is not None:
 8052                        reciprocal = now.graph.getReciprocal(here, outwards)
 8053                        destination = now.graph.getDestination(here, outwards)
 8054                    else:
 8055                        reciprocal = None
 8056                        destination = None
 8057                    scope['@r'] = reciprocal
 8058                    scope['@d'] = destination
 8059                self.runCommandBlock(value[0], scope)
 8060                value.append(value.pop(0))
 8061
 8062        elif typ == "goto":
 8063            if isinstance(value, base.DecisionSpecifier):
 8064                target: base.AnyDecisionSpecifier = value
 8065                # use moveWhich provided as argument
 8066            elif isinstance(value, tuple):
 8067                target, moveWhich = cast(
 8068                    Tuple[base.AnyDecisionSpecifier, base.FocalPointName],
 8069                    value
 8070                )
 8071            else:
 8072                target = cast(base.AnyDecisionSpecifier, value)
 8073                # use moveWhich provided as argument
 8074
 8075            destID = now.graph.resolveDecision(target)
 8076            base.updatePosition(now, destID, applyTo, moveWhich)
 8077            return destID
 8078
 8079        elif typ == "bounce":
 8080            # Just need to let the caller know they should cancel
 8081            if where is None:
 8082                raise ValueError(
 8083                    "Can't apply a 'bounce' effect without a position"
 8084                    " to apply it from."
 8085                )
 8086            return now.graph.resolveDecision(where[0])
 8087
 8088        elif typ == "follow":
 8089            if where is None:
 8090                raise ValueError(
 8091                    f"Can't follow transition {value!r} because there"
 8092                    f" is no position information when applying the"
 8093                    f" effect."
 8094                )
 8095            if where[1] is not None:
 8096                followFrom = now.graph.getDestination(where[0], where[1])
 8097                if followFrom is None:
 8098                    raise ValueError(
 8099                        f"Can't follow transition {value!r} because the"
 8100                        f" position information specifies transition"
 8101                        f" {where[1]!r} from decision"
 8102                        f" {now.graph.identityOf(where[0])} but that"
 8103                        f" transition does not exist."
 8104                    )
 8105            else:
 8106                followFrom = now.graph.resolveDecision(where[0])
 8107
 8108            following = cast(base.Transition, value)
 8109
 8110            followTo = now.graph.getDestination(followFrom, following)
 8111
 8112            if followTo is None:
 8113                raise ValueError(
 8114                    f"Can't follow transition {following!r} because"
 8115                    f" that transition doesn't exist at the specified"
 8116                    f" destination {now.graph.identityOf(followFrom)}."
 8117                )
 8118
 8119            if self.isTraversable(followFrom, following):  # skip if not
 8120                # Perform initial position update before following new
 8121                # transition:
 8122                base.updatePosition(
 8123                    now,
 8124                    followFrom,
 8125                    applyTo,
 8126                    moveWhich
 8127                )
 8128
 8129                # Apply consequences of followed transition
 8130                fullFollowTo = self.applyTransitionConsequence(
 8131                    followFrom,
 8132                    following,
 8133                    moveWhich,
 8134                    challengePolicy
 8135                )
 8136
 8137                # Now update to end of followed transition
 8138                if fullFollowTo is None:
 8139                    base.updatePosition(
 8140                        now,
 8141                        followTo,
 8142                        applyTo,
 8143                        moveWhich
 8144                    )
 8145                    fullFollowTo = followTo
 8146
 8147                # Skip the normal update: we've taken care of that plus more
 8148                return fullFollowTo
 8149            else:
 8150                # Normal position updates still applies since follow
 8151                # transition wasn't possible
 8152                return None
 8153
 8154        elif typ == "save":
 8155            assert isinstance(value, base.SaveSlot)
 8156            now.saves[value] = copy.deepcopy((now.graph, now.state))
 8157
 8158        else:
 8159            raise ValueError(f"Invalid effect type {typ!r}.")
 8160
 8161        return None  # default return value if we didn't return above
 8162
 8163    def applyExtraneousConsequence(
 8164        self,
 8165        consequence: base.Consequence,
 8166        where: Optional[
 8167            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
 8168        ] = None,
 8169        moveWhich: Optional[base.FocalPointName] = None
 8170    ) -> Optional[base.DecisionID]:
 8171        """
 8172        Applies an extraneous consequence not associated with a
 8173        transition. Unlike `applyTransitionConsequence`, the provided
 8174        `base.Consequence` must already have observed outcomes (see
 8175        `base.observeChallengeOutcomes`). Returns the decision ID for a
 8176        decision implied by a goto, follow, or bounce effect, or `None`
 8177        if no effect implies a destination.
 8178
 8179        The `where` and `moveWhich` optional arguments specify which
 8180        decision and/or transition to use as the application position,
 8181        and/or which focal point to move. This affects mechanism lookup
 8182        as well as the end position when 'follow' effects are used.
 8183        Specifically:
 8184
 8185        - A 'follow' trigger will search for transitions to follow from
 8186            the destination of the specified transition, or if only a
 8187            decision was supplied, from that decision.
 8188        - Mechanism lookups will start with both ends of the specified
 8189            transition as their search field (or with just the specified
 8190            decision if no transition is included).
 8191
 8192        'bounce' effects will cause an error unless position information
 8193        is provided, and will set the position to the base decision
 8194        provided in `where`.
 8195
 8196        Note: callers should update any situation-based variables
 8197        immediately after calling this as a 'revert' effect could change
 8198        the current graph and/or state and other changes could get lost
 8199        if they get applied to a stale graph/state.
 8200
 8201        # TODO: Examples for goto and follow effects.
 8202        """
 8203        now = self.getSituation()
 8204        searchFrom = set()
 8205        if where is not None:
 8206            if where[1] is not None:
 8207                searchFrom = now.graph.bothEnds(where[0], where[1])
 8208            else:
 8209                searchFrom = {now.graph.resolveDecision(where[0])}
 8210
 8211        context = base.RequirementContext(
 8212            state=now.state,
 8213            graph=now.graph,
 8214            searchFrom=searchFrom
 8215        )
 8216
 8217        effectIndices = base.observedEffects(context, consequence)
 8218        destID = None
 8219        for index in effectIndices:
 8220            effect = base.consequencePart(consequence, index)
 8221            if not isinstance(effect, dict) or 'value' not in effect:
 8222                raise RuntimeError(
 8223                    f"Invalid effect index {index}: Consequence part at"
 8224                    f" that index is not an Effect. Got:\n{effect}"
 8225                )
 8226            effect = cast(base.Effect, effect)
 8227            destID = self.applyExtraneousEffect(
 8228                effect,
 8229                where,
 8230                moveWhich
 8231            )
 8232            # technically this variable is not used later in this
 8233            # function, but the `applyExtraneousEffect` call means it
 8234            # needs an update, so we're doing that in case someone later
 8235            # adds code to this function that uses 'now' after this
 8236            # point.
 8237            now = self.getSituation()
 8238
 8239        return destID
 8240
 8241    def applyTransitionConsequence(
 8242        self,
 8243        decision: base.AnyDecisionSpecifier,
 8244        transition: base.AnyTransition,
 8245        moveWhich: Optional[base.FocalPointName] = None,
 8246        policy: base.ChallengePolicy = "specified",
 8247        fromIndex: Optional[int] = None,
 8248        toIndex: Optional[int] = None
 8249    ) -> Optional[base.DecisionID]:
 8250        """
 8251        Applies the effects of the specified transition to the current
 8252        graph and state, possibly overriding observed outcomes using
 8253        outcomes specified as part of a `base.TransitionWithOutcomes`.
 8254
 8255        The `where` and `moveWhich` function serve the same purpose as
 8256        for `applyExtraneousEffect`. If `where` is `None`, then the
 8257        effects will be applied as extraneous effects, meaning that
 8258        their delay and charges values will be ignored and their trigger
 8259        count will not be tracked. If `where` is supplied
 8260
 8261        Returns either None to indicate that the position update for the
 8262        transition should apply as usual, or a decision ID indicating
 8263        another destination which has already been applied by a
 8264        transition effect.
 8265
 8266        If `fromIndex` and/or `toIndex` are specified, then only effects
 8267        which have indices between those two (inclusive) will be
 8268        applied, and other effects will neither apply nor be updated in
 8269        any way. Note that `onlyPart` does not override the challenge
 8270        policy: if the effects in the specified part are not applied due
 8271        to a challenge outcome, they still won't happen, including
 8272        challenge outcomes outside of that part. Also, outcomes for
 8273        challenges of the entire consequence are re-observed if the
 8274        challenge policy implies it.
 8275
 8276        Note: Anyone calling this should update any situation-based
 8277        variables immediately after the call, as a 'revert' effect may
 8278        have changed the current graph and/or state.
 8279        """
 8280        now = self.getSituation()
 8281        dID = now.graph.resolveDecision(decision)
 8282
 8283        transitionName, outcomes = base.nameAndOutcomes(transition)
 8284
 8285        searchFrom = set()
 8286        searchFrom = now.graph.bothEnds(dID, transitionName)
 8287
 8288        context = base.RequirementContext(
 8289            state=now.state,
 8290            graph=now.graph,
 8291            searchFrom=searchFrom
 8292        )
 8293
 8294        consequence = now.graph.getConsequence(dID, transitionName)
 8295
 8296        # Make sure that challenge outcomes are known
 8297        if policy != "specified":
 8298            base.resetChallengeOutcomes(consequence)
 8299        useUp = outcomes[:]
 8300        base.observeChallengeOutcomes(
 8301            context,
 8302            consequence,
 8303            location=searchFrom,
 8304            policy=policy,
 8305            knownOutcomes=useUp
 8306        )
 8307        if len(useUp) > 0:
 8308            raise ValueError(
 8309                f"More outcomes specified than challenges observed in"
 8310                f" consequence:\n{consequence}"
 8311                f"\nRemaining outcomes:\n{useUp}"
 8312            )
 8313
 8314        # Figure out which effects apply, and apply each of them
 8315        effectIndices = base.observedEffects(context, consequence)
 8316        if fromIndex is None:
 8317            fromIndex = 0
 8318
 8319        altDest = None
 8320        for index in effectIndices:
 8321            if (
 8322                index >= fromIndex
 8323            and (toIndex is None or index <= toIndex)
 8324            ):
 8325                thisDest = self.applyTransitionEffect(
 8326                    (dID, transitionName, index),
 8327                    moveWhich
 8328                )
 8329                if thisDest is not None:
 8330                    altDest = thisDest
 8331                # TODO: What if this updates state with 'revert' to a
 8332                # graph that doesn't contain the same effects?
 8333                # TODO: Update 'now' and 'context'?!
 8334        return altDest
 8335
 8336    def allDecisions(self) -> List[base.DecisionID]:
 8337        """
 8338        Returns the list of all decisions which existed at any point
 8339        within the exploration. Example:
 8340
 8341        >>> ex = DiscreteExploration()
 8342        >>> ex.start('A')
 8343        0
 8344        >>> ex.observe('A', 'right')
 8345        1
 8346        >>> ex.explore('right', 'B', 'left')
 8347        1
 8348        >>> ex.observe('B', 'right')
 8349        2
 8350        >>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
 8351        [0, 1, 2]
 8352        """
 8353        seen = set()
 8354        result = []
 8355        for situation in self:
 8356            for decision in situation.graph:
 8357                if decision not in seen:
 8358                    result.append(decision)
 8359                    seen.add(decision)
 8360
 8361        return result
 8362
 8363    def allExploredDecisions(self) -> List[base.DecisionID]:
 8364        """
 8365        Returns the list of all decisions which existed at any point
 8366        within the exploration, excluding decisions whose highest
 8367        exploration status was `noticed` or lower. May still include
 8368        decisions which don't exist in the final situation's graph due to
 8369        things like decision merging. Example:
 8370
 8371        >>> ex = DiscreteExploration()
 8372        >>> ex.start('A')
 8373        0
 8374        >>> ex.observe('A', 'right')
 8375        1
 8376        >>> ex.explore('right', 'B', 'left')
 8377        1
 8378        >>> ex.observe('B', 'right')
 8379        2
 8380        >>> graph = ex.getSituation().graph
 8381        >>> graph.addDecision('C')  # add isolated decision; doesn't set status
 8382        3
 8383        >>> ex.hasBeenVisited('C')
 8384        False
 8385        >>> ex.allExploredDecisions()
 8386        [0, 1]
 8387        >>> ex.setExplorationStatus('C', 'exploring')
 8388        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
 8389        [0, 1, 3]
 8390        >>> ex.setExplorationStatus('A', 'explored')
 8391        >>> ex.allExploredDecisions()
 8392        [0, 1, 3]
 8393        >>> ex.setExplorationStatus('A', 'unknown')
 8394        >>> # remains visisted in an earlier step
 8395        >>> ex.allExploredDecisions()
 8396        [0, 1, 3]
 8397        >>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
 8398        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
 8399        [0, 1]
 8400        """
 8401        seen = set()
 8402        result = []
 8403        for situation in self:
 8404            graph = situation.graph
 8405            for decision in graph:
 8406                if (
 8407                    decision not in seen
 8408                and base.hasBeenVisited(situation, decision)
 8409                ):
 8410                    result.append(decision)
 8411                    seen.add(decision)
 8412
 8413        return result
 8414
 8415    def allVisitedDecisions(self) -> List[base.DecisionID]:
 8416        """
 8417        Returns the list of all decisions which existed at any point
 8418        within the exploration and which were visited at least once.
 8419        Usually all of these will be present in the final situation's
 8420        graph, but sometimes merging or other factors means there might
 8421        be some that won't be. Being present on the game state's 'active'
 8422        list in a step for its domain is what counts as "being visited,"
 8423        which means that nodes which were passed through directly via a
 8424        'follow' effect won't be counted, for example.
 8425
 8426        This should usually correspond with the absence of the
 8427        'unconfirmed' tag.
 8428
 8429        Example:
 8430
 8431        >>> ex = DiscreteExploration()
 8432        >>> ex.start('A')
 8433        0
 8434        >>> ex.observe('A', 'right')
 8435        1
 8436        >>> ex.explore('right', 'B', 'left')
 8437        1
 8438        >>> ex.observe('B', 'right')
 8439        2
 8440        >>> ex.getSituation().graph.addDecision('C')  # add isolated decision
 8441        3
 8442        >>> av = ex.allVisitedDecisions()
 8443        >>> av
 8444        [0, 1]
 8445        >>> all(  # no decisions in the 'visited' list are tagged
 8446        ...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
 8447        ...     for d in av
 8448        ... )
 8449        True
 8450        >>> graph = ex.getSituation().graph
 8451        >>> 'unconfirmed' in graph.decisionTags(0)
 8452        False
 8453        >>> 'unconfirmed' in graph.decisionTags(1)
 8454        False
 8455        >>> 'unconfirmed' in graph.decisionTags(2)
 8456        True
 8457        >>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
 8458        False
 8459        """
 8460        seen = set()
 8461        result = []
 8462        for step in range(len(self)):
 8463            active = self.getActiveDecisions(step)
 8464            for dID in active:
 8465                if dID not in seen:
 8466                    result.append(dID)
 8467                    seen.add(dID)
 8468
 8469        return result
 8470
 8471    def start(
 8472        self,
 8473        decision: base.AnyDecisionSpecifier,
 8474        startCapabilities: Optional[base.CapabilitySet] = None,
 8475        setMechanismStates: Optional[
 8476            Dict[base.MechanismID, base.MechanismState]
 8477        ] = None,
 8478        setCustomState: Optional[dict] = None,
 8479        decisionType: base.DecisionType = "imposed"
 8480    ) -> base.DecisionID:
 8481        """
 8482        Sets the initial position information for a newly-relevant
 8483        domain for the current focal context. Creates a new decision
 8484        if the decision is specified by name or `DecisionSpecifier` and
 8485        that decision doesn't already exist. Returns the decision ID for
 8486        the newly-placed decision (or for the specified decision if it
 8487        already existed).
 8488
 8489        Raises a `BadStart` error if the current focal context already
 8490        has position information for the specified domain.
 8491
 8492        - The given `startCapabilities` replaces any existing
 8493            capabilities for the current focal context, although you can
 8494            leave it as the default `None` to avoid that and retain any
 8495            capabilities that have been set up already.
 8496        - The given `setMechanismStates` and `setCustomState`
 8497            dictionaries override all previous mechanism states & custom
 8498            states in the new situation. Leave these as the default
 8499            `None` to maintain those states.
 8500        - If created, the decision will be placed in the DEFAULT_DOMAIN
 8501            domain unless it's specified as a `base.DecisionSpecifier`
 8502            with a domain part, in which case that domain is used.
 8503        - If specified as a `base.DecisionSpecifier` with a zone part
 8504            and a new decision needs to be created, the decision will be
 8505            added to that zone, creating it at level 0 if necessary,
 8506            although otherwise no zone information will be changed.
 8507        - Resets the decision type to "pending" and the action taken to
 8508            `None`. Sets the decision type of the previous situation to
 8509            'imposed' (or the specified `decisionType`) and sets an
 8510            appropriate 'start' action for that situation.
 8511        - Tags the step with 'start'.
 8512        - Even in a plural- or spreading-focalized domain, you still need
 8513            to pick one decision to start at.
 8514        """
 8515        now = self.getSituation()
 8516
 8517        startID = now.graph.getDecision(decision)
 8518        zone = None
 8519        domain = base.DEFAULT_DOMAIN
 8520        if startID is None:
 8521            if isinstance(decision, base.DecisionID):
 8522                raise MissingDecisionError(
 8523                    f"Cannot start at decision {decision} because no"
 8524                    f" decision with that ID exists. Supply a name or"
 8525                    f" DecisionSpecifier if you need the start decision"
 8526                    f" to be created automatically."
 8527                )
 8528            elif isinstance(decision, base.DecisionName):
 8529                decision = base.DecisionSpecifier(
 8530                    domain=None,
 8531                    zone=None,
 8532                    name=decision
 8533                )
 8534            startID = now.graph.addDecision(
 8535                decision.name,
 8536                domain=decision.domain
 8537            )
 8538            zone = decision.zone
 8539            if decision.domain is not None:
 8540                domain = decision.domain
 8541
 8542        if zone is not None:
 8543            if now.graph.getZoneInfo(zone) is None:
 8544                now.graph.createZone(zone, 0)
 8545            now.graph.addDecisionToZone(startID, zone)
 8546
 8547        action: base.ExplorationAction = (
 8548            'start',
 8549            startID,
 8550            startID,
 8551            domain,
 8552            startCapabilities,
 8553            setMechanismStates,
 8554            setCustomState
 8555        )
 8556
 8557        self.advanceSituation(action, decisionType)
 8558
 8559        return startID
 8560
 8561    def hasBeenVisited(
 8562        self,
 8563        decision: base.AnyDecisionSpecifier,
 8564        step: int = -1
 8565    ):
 8566        """
 8567        Returns whether or not the specified decision has been visited in
 8568        the specified step (default current step).
 8569        """
 8570        return base.hasBeenVisited(self.getSituation(step), decision)
 8571
 8572    def setExplorationStatus(
 8573        self,
 8574        decision: base.AnyDecisionSpecifier,
 8575        status: base.ExplorationStatus,
 8576        upgradeOnly: bool = False
 8577    ):
 8578        """
 8579        Updates the current exploration status of a specific decision in
 8580        the current situation. If `upgradeOnly` is true (default is
 8581        `False` then the update will only apply if the new exploration
 8582        status counts as 'more-explored' than the old one (see
 8583        `base.moreExplored`).
 8584        """
 8585        base.setExplorationStatus(
 8586            self.getSituation(),
 8587            decision,
 8588            status,
 8589            upgradeOnly
 8590        )
 8591
 8592    def getExplorationStatus(
 8593        self,
 8594        decision: base.AnyDecisionSpecifier,
 8595        step: int = -1
 8596    ):
 8597        """
 8598        Returns the exploration status of the specified decision at the
 8599        specified step (default is last step). Decisions whose
 8600        exploration status has never been set will have a default status
 8601        of 'unknown'.
 8602        """
 8603        situation = self.getSituation(step)
 8604        dID = situation.graph.resolveDecision(decision)
 8605        return situation.state['exploration'].get(dID, 'unknown')
 8606
 8607    def deduceTransitionDetailsAtStep(
 8608        self,
 8609        step: int,
 8610        transition: base.Transition,
 8611        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 8612        whichFocus: Optional[base.FocalPointSpecifier] = None,
 8613        inCommon: Union[bool, Literal["auto"]] = "auto"
 8614    ) -> Tuple[
 8615        base.ContextSpecifier,
 8616        base.DecisionID,
 8617        base.DecisionID,
 8618        Optional[base.FocalPointSpecifier]
 8619    ]:
 8620        """
 8621        Given just a transition name which the player intends to take in
 8622        a specific step, deduces the `ContextSpecifier` for which
 8623        context should be updated, the source and destination
 8624        `DecisionID`s for the transition, and if the destination
 8625        decision's domain is plural-focalized, the `FocalPointName`
 8626        specifying which focal point should be moved.
 8627
 8628        Because many of those things are ambiguous, you may get an
 8629        `AmbiguousTransitionError` when things are underspecified, and
 8630        there are options for specifying some of the extra information
 8631        directly:
 8632
 8633        - `fromDecision` may be used to specify the source decision.
 8634        - `whichFocus` may be used to specify the focal point (within a
 8635            particular context/domain) being updated. When focal point
 8636            ambiguity remains and this is unspecified, the
 8637            alphabetically-earliest relevant focal point will be used
 8638            (either among all focal points which activate the source
 8639            decision, if there are any, or among all focal points for
 8640            the entire domain of the destination decision).
 8641        - `inCommon` (a `ContextSpecifier`) may be used to specify which
 8642            context to update. The default of "auto" will cause the
 8643            active context to be selected unless it does not activate
 8644            the source decision, in which case the common context will
 8645            be selected.
 8646
 8647        A `MissingDecisionError` will be raised if there are no current
 8648        active decisions (e.g., before `start` has been called), and a
 8649        `MissingTransitionError` will be raised if the listed transition
 8650        does not exist from any active decision (or from the specified
 8651        decision if `fromDecision` is used).
 8652        """
 8653        now = self.getSituation(step)
 8654        active = self.getActiveDecisions(step)
 8655        if len(active) == 0:
 8656            raise MissingDecisionError(
 8657                f"There are no active decisions from which transition"
 8658                f" {repr(transition)} could be taken at step {step}."
 8659            )
 8660
 8661        # All source/destination decision pairs for transitions with the
 8662        # given transition name.
 8663        allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {}
 8664
 8665        # TODO: When should we be trimming the active decisions to match
 8666        # any alterations to the graph?
 8667        for dID in active:
 8668            outgoing = now.graph.destinationsFrom(dID)
 8669            if transition in outgoing:
 8670                allDecisionPairs[dID] = outgoing[transition]
 8671
 8672        if len(allDecisionPairs) == 0:
 8673            raise MissingTransitionError(
 8674                f"No transitions named {repr(transition)} are outgoing"
 8675                f" from active decisions at step {step}."
 8676                f"\nActive decisions are:"
 8677                f"\n{now.graph.namesListing(active)}"
 8678            )
 8679
 8680        if (
 8681            fromDecision is not None
 8682        and fromDecision not in allDecisionPairs
 8683        ):
 8684            raise MissingTransitionError(
 8685                f"{fromDecision} was specified as the source decision"
 8686                f" for traversing transition {repr(transition)} but"
 8687                f" there is no transition of that name from that"
 8688                f" decision at step {step}."
 8689                f"\nValid source decisions are:"
 8690                f"\n{now.graph.namesListing(allDecisionPairs)}"
 8691            )
 8692        elif fromDecision is not None:
 8693            fromID = now.graph.resolveDecision(fromDecision)
 8694            destID = allDecisionPairs[fromID]
 8695            fromDomain = now.graph.domainFor(fromID)
 8696        elif len(allDecisionPairs) == 1:
 8697            fromID, destID = list(allDecisionPairs.items())[0]
 8698            fromDomain = now.graph.domainFor(fromID)
 8699        else:
 8700            fromID = None
 8701            destID = None
 8702            fromDomain = None
 8703            # Still ambiguous; resolve this below
 8704
 8705        # Use whichFocus if provided
 8706        if whichFocus is not None:
 8707            # Type/value check for whichFocus
 8708            if (
 8709                not isinstance(whichFocus, tuple)
 8710             or len(whichFocus) != 3
 8711             or whichFocus[0] not in ("active", "common")
 8712             or not isinstance(whichFocus[1], base.Domain)
 8713             or not isinstance(whichFocus[2], base.FocalPointName)
 8714            ):
 8715                raise ValueError(
 8716                    f"Invalid whichFocus value {repr(whichFocus)}."
 8717                    f"\nMust be a length-3 tuple with 'active' or 'common'"
 8718                    f" as the first element, a Domain as the second"
 8719                    f" element, and a FocalPointName as the third"
 8720                    f" element."
 8721                )
 8722
 8723            # Resolve focal point specified
 8724            fromID = base.resolvePosition(
 8725                now,
 8726                whichFocus
 8727            )
 8728            if fromID is None:
 8729                raise MissingTransitionError(
 8730                    f"Focal point {repr(whichFocus)} was specified as"
 8731                    f" the transition source, but that focal point does"
 8732                    f" not have a position."
 8733                )
 8734            else:
 8735                destID = now.graph.destination(fromID, transition)
 8736                fromDomain = now.graph.domainFor(fromID)
 8737
 8738        elif fromID is None:  # whichFocus is None, so it can't disambiguate
 8739            raise AmbiguousTransitionError(
 8740                f"Transition {repr(transition)} was selected for"
 8741                f" disambiguation, but there are multiple transitions"
 8742                f" with that name from currently-active decisions, and"
 8743                f" neither fromDecision nor whichFocus adequately"
 8744                f" disambiguates the specific transition taken."
 8745                f"\nValid source decisions at step {step} are:"
 8746                f"\n{now.graph.namesListing(allDecisionPairs)}"
 8747            )
 8748
 8749        # At this point, fromID, destID, and fromDomain have
 8750        # been resolved.
 8751        if fromID is None or destID is None or fromDomain is None:
 8752            raise RuntimeError(
 8753                f"One of fromID, destID, or fromDomain was None after"
 8754                f" disambiguation was finished:"
 8755                f"\nfromID: {fromID}, destID: {destID}, fromDomain:"
 8756                f" {repr(fromDomain)}"
 8757            )
 8758
 8759        # Now figure out which context activated the source so we know
 8760        # which focal point we're moving:
 8761        context = self.getActiveContext()
 8762        active = base.activeDecisionSet(context)
 8763        using: base.ContextSpecifier = "active"
 8764        if fromID not in active:
 8765            context = self.getCommonContext(step)
 8766            using = "common"
 8767
 8768        destDomain = now.graph.domainFor(destID)
 8769        if (
 8770            whichFocus is None
 8771        and base.getDomainFocalization(context, destDomain) == 'plural'
 8772        ):
 8773            # Need to figure out which focal point is moving; use the
 8774            # alphabetically earliest one that's positioned at the
 8775            # fromID, or just the earliest one overall if none of them
 8776            # are there.
 8777            contextFocalPoints: Dict[
 8778                base.FocalPointName,
 8779                Optional[base.DecisionID]
 8780            ] = cast(
 8781                Dict[base.FocalPointName, Optional[base.DecisionID]],
 8782                context['activeDecisions'][destDomain]
 8783            )
 8784            if not isinstance(contextFocalPoints, dict):
 8785                raise RuntimeError(
 8786                    f"Active decisions specifier for domain"
 8787                    f" {repr(destDomain)} with plural focalization has"
 8788                    f" a non-dictionary value."
 8789                )
 8790
 8791            if fromDomain == destDomain:
 8792                focalCandidates = [
 8793                    fp
 8794                    for fp, pos in contextFocalPoints.items()
 8795                    if pos == fromID
 8796                ]
 8797            else:
 8798                focalCandidates = list(contextFocalPoints)
 8799
 8800            whichFocus = (using, destDomain, min(focalCandidates))
 8801
 8802        # Now whichFocus has been set if it wasn't already specified;
 8803        # might still be None if it's not relevant.
 8804        return (using, fromID, destID, whichFocus)
 8805
 8806    def advanceSituation(
 8807        self,
 8808        action: base.ExplorationAction,
 8809        decisionType: base.DecisionType = "active",
 8810        challengePolicy: base.ChallengePolicy = "specified"
 8811    ) -> Tuple[base.Situation, Set[base.DecisionID]]:
 8812        """
 8813        Given an `ExplorationAction`, sets that as the action taken in
 8814        the current situation, and adds a new situation with the results
 8815        of that action. A `DoubleActionError` will be raised if the
 8816        current situation already has an action specified, and/or has a
 8817        decision type other than 'pending'. By default the type of the
 8818        decision will be 'active' but another `DecisionType` can be
 8819        specified via the `decisionType` parameter.
 8820
 8821        If the action specified is `('noAction',)`, then the new
 8822        situation will be a copy of the old one; this represents waiting
 8823        or being at an ending (a decision type other than 'pending'
 8824        should be used).
 8825
 8826        Although `None` can appear as the action entry in situations
 8827        with pending decisions, you cannot call `advanceSituation` with
 8828        `None` as the action.
 8829
 8830        If the action includes taking a transition whose requirements
 8831        are not satisfied, the transition will still be taken (and any
 8832        consequences applied) but a `TransitionBlockedWarning` will be
 8833        issued.
 8834
 8835        A `ChallengePolicy` may be specified, the default is 'specified'
 8836        which requires that outcomes are pre-specified. If any other
 8837        policy is set, the challenge outcomes will be reset before
 8838        re-resolving them according to the provided policy.
 8839
 8840        The new situation will have decision type 'pending' and `None`
 8841        as the action.
 8842
 8843        The new situation created as a result of the action is returned,
 8844        along with the set of destination decision IDs, including
 8845        possibly a modified destination via 'bounce', 'goto', and/or
 8846        'follow' effects. For actions that don't have a destination, the
 8847        second part of the returned tuple will be an empty set. Multiple
 8848        IDs may be in the set when using a start action in a plural- or
 8849        spreading-focalized domain, for example.
 8850
 8851        If the action updates active decisions (including via transition
 8852        effects) this will also update the exploration status of those
 8853        decisions to 'exploring' if they had been in an unvisited
 8854        status (see `updatePosition` and `hasBeenVisited`). This
 8855        includes decisions traveled through but not ultimately arrived
 8856        at via 'follow' effects.
 8857
 8858        If any decisions are active in the `ENDINGS_DOMAIN`, attempting
 8859        to 'warp', 'explore', 'take', or 'start' will raise an
 8860        `InvalidActionError`.
 8861        """
 8862        now = self.getSituation()
 8863        if now.type != 'pending' or now.action is not None:
 8864            raise DoubleActionError(
 8865                f"Attempted to take action {repr(action)} at step"
 8866                f" {len(self) - 1}, but an action and/or decision type"
 8867                f" had already been specified:"
 8868                f"\nAction: {repr(now.action)}"
 8869                f"\nType: {repr(now.type)}"
 8870            )
 8871
 8872        # Update the now situation to add in the decision type and
 8873        # action taken:
 8874        revised = base.Situation(
 8875            now.graph,
 8876            now.state,
 8877            decisionType,
 8878            action,
 8879            now.saves,
 8880            now.tags,
 8881            now.annotations
 8882        )
 8883        self.situations[-1] = revised
 8884
 8885        # Separate update process when reverting (this branch returns)
 8886        if (
 8887            action is not None
 8888        and isinstance(action, tuple)
 8889        and len(action) == 3
 8890        and action[0] == 'revertTo'
 8891        and isinstance(action[1], base.SaveSlot)
 8892        and isinstance(action[2], set)
 8893        and all(isinstance(x, str) for x in action[2])
 8894        ):
 8895            _, slot, aspects = action
 8896            if slot not in now.saves:
 8897                raise KeyError(
 8898                    f"Cannot load save slot {slot!r} because no save"
 8899                    f" data has been established for that slot."
 8900                )
 8901            load = now.saves[slot]
 8902            rGraph, rState = base.revertedState(
 8903                (now.graph, now.state),
 8904                load,
 8905                aspects
 8906            )
 8907            reverted = base.Situation(
 8908                graph=rGraph,
 8909                state=rState,
 8910                type='pending',
 8911                action=None,
 8912                saves=copy.deepcopy(now.saves),
 8913                tags={},
 8914                annotations=[]
 8915            )
 8916            self.situations.append(reverted)
 8917            # Apply any active triggers (edits reverted)
 8918            self.applyActiveTriggers()
 8919            # Figure out destinations set to return
 8920            newDestinations = set()
 8921            newPr = rState['primaryDecision']
 8922            if newPr is not None:
 8923                newDestinations.add(newPr)
 8924            return (reverted, newDestinations)
 8925
 8926        # TODO: These deep copies are expensive time-wise. Can we avoid
 8927        # them? Probably not.
 8928        newGraph = copy.deepcopy(now.graph)
 8929        newState = copy.deepcopy(now.state)
 8930        newSaves = copy.copy(now.saves)  # a shallow copy
 8931        newTags: Dict[base.Tag, base.TagValue] = {}
 8932        newAnnotations: List[base.Annotation] = []
 8933        updated = base.Situation(
 8934            graph=newGraph,
 8935            state=newState,
 8936            type='pending',
 8937            action=None,
 8938            saves=newSaves,
 8939            tags=newTags,
 8940            annotations=newAnnotations
 8941        )
 8942
 8943        targetContext: base.FocalContext
 8944
 8945        # Now that action effects have been imprinted into the updated
 8946        # situation, append it to our situations list
 8947        self.situations.append(updated)
 8948
 8949        # Figure out effects of the action:
 8950        if action is None:
 8951            raise InvalidActionError(
 8952                "None cannot be used as an action when advancing the"
 8953                " situation."
 8954            )
 8955
 8956        aLen = len(action)
 8957
 8958        destIDs = set()
 8959
 8960        if (
 8961            action[0] in ('start', 'take', 'explore', 'warp')
 8962        and any(
 8963                newGraph.domainFor(d) == ENDINGS_DOMAIN
 8964                for d in self.getActiveDecisions()
 8965            )
 8966        ):
 8967            activeEndings = [
 8968                d
 8969                for d in self.getActiveDecisions()
 8970                if newGraph.domainFor(d) == ENDINGS_DOMAIN
 8971            ]
 8972            raise InvalidActionError(
 8973                f"Attempted to {action[0]!r} while an ending was"
 8974                f" active. Active endings are:"
 8975                f"\n{newGraph.namesListing(activeEndings)}"
 8976            )
 8977
 8978        if action == ('noAction',):
 8979            # No updates needed
 8980            pass
 8981
 8982        elif (
 8983            not isinstance(action, tuple)
 8984         or (action[0] not in get_args(base.ExplorationActionType))
 8985         or not (2 <= aLen <= 7)
 8986        ):
 8987            raise InvalidActionError(
 8988                f"Invalid ExplorationAction tuple (must be a tuple that"
 8989                f" starts with an ExplorationActionType and has 2-6"
 8990                f" entries if it's not ('noAction',)):"
 8991                f"\n{repr(action)}"
 8992            )
 8993
 8994        elif action[0] == 'start':
 8995            (
 8996                _,
 8997                positionSpecifier,
 8998                primary,
 8999                domain,
 9000                capabilities,
 9001                mechanismStates,
 9002                customState
 9003            ) = cast(
 9004                Tuple[
 9005                    Literal['start'],
 9006                    Union[
 9007                        base.DecisionID,
 9008                        Dict[base.FocalPointName, base.DecisionID],
 9009                        Set[base.DecisionID]
 9010                    ],
 9011                    Optional[base.DecisionID],
 9012                    base.Domain,
 9013                    Optional[base.CapabilitySet],
 9014                    Optional[Dict[base.MechanismID, base.MechanismState]],
 9015                    Optional[dict]
 9016                ],
 9017                action
 9018            )
 9019            targetContext = newState['contexts'][
 9020                newState['activeContext']
 9021            ]
 9022
 9023            targetFocalization = base.getDomainFocalization(
 9024                targetContext,
 9025                domain
 9026            )  # sets up 'singular' as default if
 9027
 9028            # Check if there are any already-active decisions.
 9029            if targetContext['activeDecisions'][domain] is not None:
 9030                raise BadStart(
 9031                    f"Cannot start in domain {repr(domain)} because"
 9032                    f" that domain already has a position. 'start' may"
 9033                    f" only be used with domains that don't yet have"
 9034                    f" any position information."
 9035                )
 9036
 9037            # Make the domain active
 9038            if domain not in targetContext['activeDomains']:
 9039                targetContext['activeDomains'].add(domain)
 9040
 9041            # Check position info matches focalization type and update
 9042            # exploration statuses
 9043            if isinstance(positionSpecifier, base.DecisionID):
 9044                if targetFocalization != 'singular':
 9045                    raise BadStart(
 9046                        f"Invalid position specifier"
 9047                        f" {repr(positionSpecifier)} (type"
 9048                        f" {type(positionSpecifier)}). Domain"
 9049                        f" {repr(domain)} has {targetFocalization}"
 9050                        f" focalization."
 9051                    )
 9052                base.setExplorationStatus(
 9053                    updated,
 9054                    positionSpecifier,
 9055                    'exploring',
 9056                    upgradeOnly=True
 9057                )
 9058                destIDs.add(positionSpecifier)
 9059            elif isinstance(positionSpecifier, dict):
 9060                if targetFocalization != 'plural':
 9061                    raise BadStart(
 9062                        f"Invalid position specifier"
 9063                        f" {repr(positionSpecifier)} (type"
 9064                        f" {type(positionSpecifier)}). Domain"
 9065                        f" {repr(domain)} has {targetFocalization}"
 9066                        f" focalization."
 9067                    )
 9068                destIDs |= set(positionSpecifier.values())
 9069            elif isinstance(positionSpecifier, set):
 9070                if targetFocalization != 'spreading':
 9071                    raise BadStart(
 9072                        f"Invalid position specifier"
 9073                        f" {repr(positionSpecifier)} (type"
 9074                        f" {type(positionSpecifier)}). Domain"
 9075                        f" {repr(domain)} has {targetFocalization}"
 9076                        f" focalization."
 9077                    )
 9078                destIDs |= positionSpecifier
 9079            else:
 9080                raise TypeError(
 9081                    f"Invalid position specifier"
 9082                    f" {repr(positionSpecifier)} (type"
 9083                    f" {type(positionSpecifier)}). It must be a"
 9084                    f" DecisionID, a dictionary from FocalPointNames to"
 9085                    f" DecisionIDs, or a set of DecisionIDs, according"
 9086                    f" to the focalization of the relevant domain."
 9087                )
 9088
 9089            # Put specified position(s) in place
 9090            # TODO: This cast is really silly...
 9091            targetContext['activeDecisions'][domain] = cast(
 9092                Union[
 9093                    None,
 9094                    base.DecisionID,
 9095                    Dict[base.FocalPointName, Optional[base.DecisionID]],
 9096                    Set[base.DecisionID]
 9097                ],
 9098                positionSpecifier
 9099            )
 9100
 9101            # Set primary decision
 9102            newState['primaryDecision'] = primary
 9103
 9104            # Set capabilities
 9105            if capabilities is not None:
 9106                targetContext['capabilities'] = capabilities
 9107
 9108            # Set mechanism states
 9109            if mechanismStates is not None:
 9110                newState['mechanisms'] = mechanismStates
 9111
 9112            # Set custom state
 9113            if customState is not None:
 9114                newState['custom'] = customState
 9115
 9116        elif action[0] in ('explore', 'take', 'warp'):  # similar handling
 9117            assert (
 9118                len(action) == 3
 9119             or len(action) == 4
 9120             or len(action) == 6
 9121             or len(action) == 7
 9122            )
 9123            # Set up necessary variables
 9124            cSpec: base.ContextSpecifier = "active"
 9125            fromID: Optional[base.DecisionID] = None
 9126            takeTransition: Optional[base.Transition] = None
 9127            outcomes: List[bool] = []
 9128            destID: base.DecisionID  # No starting value as it's not optional
 9129            moveInDomain: Optional[base.Domain] = None
 9130            moveWhich: Optional[base.FocalPointName] = None
 9131
 9132            # Figure out target context
 9133            if isinstance(action[1], str):
 9134                if action[1] not in get_args(base.ContextSpecifier):
 9135                    raise InvalidActionError(
 9136                        f"Action specifies {repr(action[1])} context,"
 9137                        f" but that's not a valid context specifier."
 9138                        f" The valid options are:"
 9139                        f"\n{repr(get_args(base.ContextSpecifier))}"
 9140                    )
 9141                else:
 9142                    cSpec = cast(base.ContextSpecifier, action[1])
 9143            else:  # Must be a `FocalPointSpecifier`
 9144                cSpec, moveInDomain, moveWhich = cast(
 9145                    base.FocalPointSpecifier,
 9146                    action[1]
 9147                )
 9148                assert moveInDomain is not None
 9149
 9150            # Grab target context to work in
 9151            if cSpec == 'common':
 9152                targetContext = newState['common']
 9153            else:
 9154                targetContext = newState['contexts'][
 9155                    newState['activeContext']
 9156                ]
 9157
 9158            # Check focalization of the target domain
 9159            if moveInDomain is not None:
 9160                fType = base.getDomainFocalization(
 9161                    targetContext,
 9162                    moveInDomain
 9163                )
 9164                if (
 9165                    (
 9166                        isinstance(action[1], str)
 9167                    and fType == 'plural'
 9168                    ) or (
 9169                        not isinstance(action[1], str)
 9170                    and fType != 'plural'
 9171                    )
 9172                ):
 9173                    raise ImpossibleActionError(
 9174                        f"Invalid ExplorationAction (moves in"
 9175                        f" plural-focalized domains must include a"
 9176                        f" FocalPointSpecifier, while moves in"
 9177                        f" non-plural-focalized domains must not."
 9178                        f" Domain {repr(moveInDomain)} is"
 9179                        f" {fType}-focalized):"
 9180                        f"\n{repr(action)}"
 9181                    )
 9182
 9183            if action[0] == "warp":
 9184                # It's a warp, so destination is specified directly
 9185                if not isinstance(action[2], base.DecisionID):
 9186                    raise TypeError(
 9187                        f"Invalid ExplorationAction tuple (third part"
 9188                        f" must be a decision ID for 'warp' actions):"
 9189                        f"\n{repr(action)}"
 9190                    )
 9191                else:
 9192                    destID = cast(base.DecisionID, action[2])
 9193
 9194            elif aLen == 4 or aLen == 7:
 9195                # direct 'take' or 'explore'
 9196                fromID = cast(base.DecisionID, action[2])
 9197                takeTransition, outcomes = cast(
 9198                    base.TransitionWithOutcomes,
 9199                    action[3]  # type: ignore [misc]
 9200                )
 9201                if (
 9202                    not isinstance(fromID, base.DecisionID)
 9203                 or not isinstance(takeTransition, base.Transition)
 9204                ):
 9205                    raise InvalidActionError(
 9206                        f"Invalid ExplorationAction tuple (for 'take' or"
 9207                        f" 'explore', if the length is 4/7, parts 2-4"
 9208                        f" must be a context specifier, a decision ID, and a"
 9209                        f" transition name. Got:"
 9210                        f"\n{repr(action)}"
 9211                    )
 9212
 9213                try:
 9214                    destID = newGraph.destination(fromID, takeTransition)
 9215                except MissingDecisionError:
 9216                    raise ImpossibleActionError(
 9217                        f"Invalid ExplorationAction: move from decision"
 9218                        f" {fromID} is invalid because there is no"
 9219                        f" decision with that ID in the current"
 9220                        f" graph."
 9221                        f"\nValid decisions are:"
 9222                        f"\n{newGraph.namesListing(newGraph)}"
 9223                    )
 9224                except MissingTransitionError:
 9225                    valid = newGraph.destinationsFrom(fromID)
 9226                    listing = newGraph.destinationsListing(valid)
 9227                    raise ImpossibleActionError(
 9228                        f"Invalid ExplorationAction: move from decision"
 9229                        f" {newGraph.identityOf(fromID)}"
 9230                        f" along transition {repr(takeTransition)} is"
 9231                        f" invalid because there is no such transition"
 9232                        f" at that decision."
 9233                        f"\nValid transitions there are:"
 9234                        f"\n{listing}"
 9235                    )
 9236                targetActive = targetContext['activeDecisions']
 9237                if moveInDomain is not None:
 9238                    activeInDomain = targetActive[moveInDomain]
 9239                    if (
 9240                        (
 9241                            isinstance(activeInDomain, base.DecisionID)
 9242                        and fromID != activeInDomain
 9243                        )
 9244                     or (
 9245                            isinstance(activeInDomain, set)
 9246                        and fromID not in activeInDomain
 9247                        )
 9248                     or (
 9249                            isinstance(activeInDomain, dict)
 9250                        and fromID not in activeInDomain.values()
 9251                        )
 9252                    ):
 9253                        raise ImpossibleActionError(
 9254                            f"Invalid ExplorationAction: move from"
 9255                            f" decision {fromID} is invalid because"
 9256                            f" that decision is not active in domain"
 9257                            f" {repr(moveInDomain)} in the current"
 9258                            f" graph."
 9259                            f"\nValid decisions are:"
 9260                            f"\n{newGraph.namesListing(newGraph)}"
 9261                        )
 9262
 9263            elif aLen == 3 or aLen == 6:
 9264                # 'take' or 'explore' focal point
 9265                # We know that moveInDomain is not None here.
 9266                assert moveInDomain is not None
 9267                if not isinstance(action[2], base.Transition):
 9268                    raise InvalidActionError(
 9269                        f"Invalid ExplorationAction tuple (for 'take'"
 9270                        f" actions if the second part is a"
 9271                        f" FocalPointSpecifier the third part must be a"
 9272                        f" transition name):"
 9273                        f"\n{repr(action)}"
 9274                    )
 9275
 9276                takeTransition, outcomes = cast(
 9277                    base.TransitionWithOutcomes,
 9278                    action[2]
 9279                )
 9280                targetActive = targetContext['activeDecisions']
 9281                activeInDomain = cast(
 9282                    Dict[base.FocalPointName, Optional[base.DecisionID]],
 9283                    targetActive[moveInDomain]
 9284                )
 9285                if (
 9286                    moveInDomain is not None
 9287                and (
 9288                        not isinstance(activeInDomain, dict)
 9289                     or moveWhich not in activeInDomain
 9290                    )
 9291                ):
 9292                    raise ImpossibleActionError(
 9293                        f"Invalid ExplorationAction: move of focal"
 9294                        f" point {repr(moveWhich)} in domain"
 9295                        f" {repr(moveInDomain)} is invalid because"
 9296                        f" that domain does not have a focal point"
 9297                        f" with that name."
 9298                    )
 9299                fromID = activeInDomain[moveWhich]
 9300                if fromID is None:
 9301                    raise ImpossibleActionError(
 9302                        f"Invalid ExplorationAction: move of focal"
 9303                        f" point {repr(moveWhich)} in domain"
 9304                        f" {repr(moveInDomain)} is invalid because"
 9305                        f" that focal point does not have a position"
 9306                        f" at this step."
 9307                    )
 9308                try:
 9309                    destID = newGraph.destination(fromID, takeTransition)
 9310                except MissingDecisionError:
 9311                    raise ImpossibleActionError(
 9312                        f"Invalid exploration state: focal point"
 9313                        f" {repr(moveWhich)} in domain"
 9314                        f" {repr(moveInDomain)} specifies decision"
 9315                        f" {fromID} as the current position, but"
 9316                        f" that decision does not exist!"
 9317                    )
 9318                except MissingTransitionError:
 9319                    valid = newGraph.destinationsFrom(fromID)
 9320                    listing = newGraph.destinationsListing(valid)
 9321                    raise ImpossibleActionError(
 9322                        f"Invalid ExplorationAction: move of focal"
 9323                        f" point {repr(moveWhich)} in domain"
 9324                        f" {repr(moveInDomain)} along transition"
 9325                        f" {repr(takeTransition)} is invalid because"
 9326                        f" that focal point is at decision"
 9327                        f" {newGraph.identityOf(fromID)} and that"
 9328                        f" decision does not have an outgoing"
 9329                        f" transition with that name.\nValid"
 9330                        f" transitions from that decision are:"
 9331                        f"\n{listing}"
 9332                    )
 9333
 9334            else:
 9335                raise InvalidActionError(
 9336                    f"Invalid ExplorationAction: unrecognized"
 9337                    f" 'explore', 'take' or 'warp' format:"
 9338                    f"\n{action}"
 9339                )
 9340
 9341            # If we're exploring, update information for the destination
 9342            if action[0] == 'explore':
 9343                zone = cast(
 9344                    Union[base.Zone, None, type[base.DefaultZone]],
 9345                    action[-1]
 9346                )
 9347                recipName = cast(Optional[base.Transition], action[-2])
 9348                destOrName = cast(
 9349                    Union[base.DecisionName, base.DecisionID, None],
 9350                    action[-3]
 9351                )
 9352                if isinstance(destOrName, base.DecisionID):
 9353                    destID = destOrName
 9354
 9355                if fromID is None or takeTransition is None:
 9356                    raise ImpossibleActionError(
 9357                        f"Invalid ExplorationAction: exploration"
 9358                        f" has unclear origin decision or transition."
 9359                        f" Got:\n{action}"
 9360                    )
 9361
 9362                currentDest = newGraph.destination(fromID, takeTransition)
 9363                if not newGraph.isConfirmed(currentDest):
 9364                    newGraph.replaceUnconfirmed(
 9365                        fromID,
 9366                        takeTransition,
 9367                        destOrName,
 9368                        recipName,
 9369                        placeInZone=zone,
 9370                        forceNew=not isinstance(destOrName, base.DecisionID)
 9371                    )
 9372                else:
 9373                    # Otherwise, since the destination already existed
 9374                    # and was hooked up at the right decision, no graph
 9375                    # edits need to be made, unless we need to rename
 9376                    # the reciprocal.
 9377                    # TODO: Do we care about zones here?
 9378                    if recipName is not None:
 9379                        oldReciprocal = newGraph.getReciprocal(
 9380                            fromID,
 9381                            takeTransition
 9382                        )
 9383                        if (
 9384                            oldReciprocal is not None
 9385                        and oldReciprocal != recipName
 9386                        ):
 9387                            newGraph.addTransition(
 9388                                destID,
 9389                                recipName,
 9390                                fromID,
 9391                                None
 9392                            )
 9393                            newGraph.setReciprocal(
 9394                                destID,
 9395                                recipName,
 9396                                takeTransition,
 9397                                setBoth=True
 9398                            )
 9399                            newGraph.mergeTransitions(
 9400                                destID,
 9401                                oldReciprocal,
 9402                                recipName
 9403                            )
 9404
 9405            # If we are moving along a transition, check requirements
 9406            # and apply transition effects *before* updating our
 9407            # position, and check that they don't cancel the normal
 9408            # position update
 9409            finalDest = None
 9410            if takeTransition is not None:
 9411                assert fromID is not None  # both or neither
 9412                if not self.isTraversable(fromID, takeTransition):
 9413                    req = now.graph.getTransitionRequirement(
 9414                        fromID,
 9415                        takeTransition
 9416                    )
 9417                    # TODO: Alter warning message if transition is
 9418                    # deactivated vs. requirement not satisfied
 9419                    warnings.warn(
 9420                        (
 9421                            f"The requirements for transition"
 9422                            f" {takeTransition!r} from decision"
 9423                            f" {now.graph.identityOf(fromID)} are"
 9424                            f" not met at step {len(self) - 1} (or that"
 9425                            f" transition has been deactivated):\n{req}"
 9426                        ),
 9427                        TransitionBlockedWarning
 9428                    )
 9429
 9430                # Apply transition consequences to our new state and
 9431                # figure out if we need to skip our normal update or not
 9432                finalDest = self.applyTransitionConsequence(
 9433                    fromID,
 9434                    (takeTransition, outcomes),
 9435                    moveWhich,
 9436                    challengePolicy
 9437                )
 9438
 9439            # Check moveInDomain
 9440            destDomain = newGraph.domainFor(destID)
 9441            if moveInDomain is not None and moveInDomain != destDomain:
 9442                raise ImpossibleActionError(
 9443                    f"Invalid ExplorationAction: move specified"
 9444                    f" domain {repr(moveInDomain)} as the domain of"
 9445                    f" the focal point to move, but the destination"
 9446                    f" of the move is {now.graph.identityOf(destID)}"
 9447                    f" which is in domain {repr(destDomain)}, so focal"
 9448                    f" point {repr(moveWhich)} cannot be moved there."
 9449                )
 9450
 9451            # Now that we know where we're going, update position
 9452            # information (assuming it wasn't already set):
 9453            if finalDest is None:
 9454                finalDest = destID
 9455                base.updatePosition(
 9456                    updated,
 9457                    destID,
 9458                    cSpec,
 9459                    moveWhich
 9460                )
 9461
 9462            destIDs.add(finalDest)
 9463
 9464        elif action[0] == "focus":
 9465            # Figure out target context
 9466            action = cast(
 9467                Tuple[
 9468                    Literal['focus'],
 9469                    base.ContextSpecifier,
 9470                    Set[base.Domain],
 9471                    Set[base.Domain]
 9472                ],
 9473                action
 9474            )
 9475            contextSpecifier: base.ContextSpecifier = action[1]
 9476            if contextSpecifier == 'common':
 9477                targetContext = newState['common']
 9478            else:
 9479                targetContext = newState['contexts'][
 9480                    newState['activeContext']
 9481                ]
 9482
 9483            # Just need to swap out active domains
 9484            goingOut, comingIn = cast(
 9485                Tuple[Set[base.Domain], Set[base.Domain]],
 9486                action[2:]
 9487            )
 9488            if (
 9489                not isinstance(goingOut, set)
 9490             or not isinstance(comingIn, set)
 9491             or not all(isinstance(d, base.Domain) for d in goingOut)
 9492             or not all(isinstance(d, base.Domain) for d in comingIn)
 9493            ):
 9494                raise InvalidActionError(
 9495                    f"Invalid ExplorationAction tuple (must have 4"
 9496                    f" parts if the first part is 'focus' and"
 9497                    f" the third and fourth parts must be sets of"
 9498                    f" domains):"
 9499                    f"\n{repr(action)}"
 9500                )
 9501            activeSet = targetContext['activeDomains']
 9502            for dom in goingOut:
 9503                try:
 9504                    activeSet.remove(dom)
 9505                except KeyError:
 9506                    warnings.warn(
 9507                        (
 9508                            f"Domain {repr(dom)} was deactivated at"
 9509                            f" step {len(self)} but it was already"
 9510                            f" inactive at that point."
 9511                        ),
 9512                        InactiveDomainWarning
 9513                    )
 9514            # TODO: Also warn for doubly-activated domains?
 9515            activeSet |= comingIn
 9516
 9517            # destIDs remains empty in this case
 9518
 9519        elif action[0] == 'swap':  # update which `FocalContext` is active
 9520            newContext = cast(base.FocalContextName, action[1])
 9521            if newContext not in newState['contexts']:
 9522                raise MissingFocalContextError(
 9523                    f"'swap' action with target {repr(newContext)} is"
 9524                    f" invalid because no context with that name"
 9525                    f" exists."
 9526                )
 9527            newState['activeContext'] = newContext
 9528
 9529            # destIDs remains empty in this case
 9530
 9531        elif action[0] == 'focalize':  # create new `FocalContext`
 9532            newContext = cast(base.FocalContextName, action[1])
 9533            if newContext in newState['contexts']:
 9534                raise FocalContextCollisionError(
 9535                    f"'focalize' action with target {repr(newContext)}"
 9536                    f" is invalid because a context with that name"
 9537                    f" already exists."
 9538                )
 9539            newState['contexts'][newContext] = base.emptyFocalContext()
 9540            newState['activeContext'] = newContext
 9541
 9542            # destIDs remains empty in this case
 9543
 9544        # revertTo is handled above
 9545        else:
 9546            raise InvalidActionError(
 9547                f"Invalid ExplorationAction tuple (first item must be"
 9548                f" an ExplorationActionType, and tuple must be length-1"
 9549                f" if the action type is 'noAction'):"
 9550                f"\n{repr(action)}"
 9551            )
 9552
 9553        # Apply any active triggers
 9554        followTo = self.applyActiveTriggers()
 9555        if followTo is not None:
 9556            destIDs.add(followTo)
 9557            # TODO: Re-work to work with multiple position updates in
 9558            # different focal contexts, domains, and/or for different
 9559            # focal points in plural-focalized domains.
 9560
 9561        return (updated, destIDs)
 9562
 9563    def applyActiveTriggers(self) -> Optional[base.DecisionID]:
 9564        """
 9565        Finds all actions with the 'trigger' tag attached to currently
 9566        active decisions, and applies their effects if their requirements
 9567        are met (ordered by decision-ID with ties broken alphabetically
 9568        by action name).
 9569
 9570        'bounce', 'goto' and 'follow' effects may apply. However, any
 9571        new triggers that would be activated because of decisions
 9572        reached by such effects will not apply. Note that 'bounce'
 9573        effects update position to the decision where the action was
 9574        attached, which is usually a no-op. This function returns the
 9575        decision ID of the decision reached by the last decision-moving
 9576        effect applied, or `None` if no such effects triggered.
 9577
 9578        TODO: What about situations where positions are updated in
 9579        multiple domains or multiple foal points in a plural domain are
 9580        independently updated?
 9581
 9582        TODO: Tests for this!
 9583        """
 9584        active = self.getActiveDecisions()
 9585        now = self.getSituation()
 9586        graph = now.graph
 9587        finalFollow = None
 9588        for decision in sorted(active):
 9589            for action in graph.decisionActions(decision):
 9590                if (
 9591                    'trigger' in graph.transitionTags(decision, action)
 9592                and self.isTraversable(decision, action)
 9593                ):
 9594                    followTo = self.applyTransitionConsequence(
 9595                        decision,
 9596                        action
 9597                    )
 9598                    if followTo is not None:
 9599                        # TODO: How will triggers interact with
 9600                        # plural-focalized domains? Probably need to fix
 9601                        # this to detect moveWhich based on which focal
 9602                        # points are at the decision where the transition
 9603                        # is, and then apply this to each of them?
 9604                        base.updatePosition(now, followTo)
 9605                        finalFollow = followTo
 9606
 9607        return finalFollow
 9608
 9609    def explore(
 9610        self,
 9611        transition: base.AnyTransition,
 9612        destination: Union[base.DecisionName, base.DecisionID, None],
 9613        reciprocal: Optional[base.Transition] = None,
 9614        zone: Union[
 9615            base.Zone,
 9616            type[base.DefaultZone],
 9617            None
 9618        ] = base.DefaultZone,
 9619        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9620        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9621        inCommon: Union[bool, Literal["auto"]] = "auto",
 9622        decisionType: base.DecisionType = "active",
 9623        challengePolicy: base.ChallengePolicy = "specified"
 9624    ) -> base.DecisionID:
 9625        """
 9626        Adds a new situation to the exploration representing the
 9627        traversal of the specified transition (possibly with outcomes
 9628        specified for challenges among that transitions consequences).
 9629        Uses `deduceTransitionDetailsAtStep` to figure out from the
 9630        transition name which specific transition is taken (and which
 9631        focal point is updated if necessary). This uses the
 9632        `fromDecision`, `whichFocus`, and `inCommon` optional
 9633        parameters, and also determines whether to update the common or
 9634        the active `FocalContext`. Sets the exploration status of the
 9635        decision explored to 'exploring'. Returns the decision ID for
 9636        the destination reached, accounting for goto/bounce/follow
 9637        effects that might have triggered.
 9638
 9639        The `destination` will be used to name the newly-explored
 9640        decision, except when it's a `DecisionID`, in which case that
 9641        decision must be unvisited, and we'll connect the specified
 9642        transition to that decision.
 9643
 9644        The focalization of the destination domain in the context to be
 9645        updated determines how active decisions are changed:
 9646
 9647        - If the destination domain is focalized as 'single', then in
 9648            the subsequent `Situation`, the destination decision will
 9649            become the single active decision in that domain.
 9650        - If it's focalized as 'plural', then one of the
 9651            `FocalPointName`s for that domain will be moved to activate
 9652            that decision; which one can be specified using `whichFocus`
 9653            or if left unspecified, will be deduced: if the starting
 9654            decision is in the same domain, then the
 9655            alphabetically-earliest focal point which is at the starting
 9656            decision will be moved. If the starting position is in a
 9657            different domain, then the alphabetically earliest focal
 9658            point among all focal points in the destination domain will
 9659            be moved.
 9660        - If it's focalized as 'spreading', then the destination
 9661            decision will be added to the set of active decisions in
 9662            that domain, without removing any.
 9663
 9664        The transition named must have been pointing to an unvisited
 9665        decision (see `hasBeenVisited`), and the name of that decision
 9666        will be updated if a `destination` value is given (a
 9667        `DecisionCollisionWarning` will be issued if the destination
 9668        name is a duplicate of another name in the graph, although this
 9669        is not an error). Additionally:
 9670
 9671        - If a `reciprocal` name is specified, the reciprocal transition
 9672            will be renamed using that name, or created with that name if
 9673            it didn't already exist. If reciprocal is left as `None` (the
 9674            default) then no change will be made to the reciprocal
 9675            transition, and it will not be created if it doesn't exist.
 9676        - If a `zone` is specified, the newly-explored decision will be
 9677            added to that zone (and that zone will be created at level 0
 9678            if it didn't already exist). If `zone` is set to `None` then
 9679            it will not be added to any new zones. If `zone` is left as
 9680            the default (the `DefaultZone` class) then the explored
 9681            decision will be added to each zone that the decision it was
 9682            explored from is a part of. If a zone needs to be created,
 9683            that zone will be added as a sub-zone of each zone which is a
 9684            parent of a zone that directly contains the origin decision.
 9685        - An `ExplorationStatusError` will be raised if the specified
 9686            transition leads to a decision whose `ExplorationStatus` is
 9687            'exploring' or higher (i.e., `hasBeenVisited`). (Use
 9688            `returnTo` instead to adjust things when a transition to an
 9689            unknown destination turns out to lead to an already-known
 9690            destination.)
 9691        - A `TransitionBlockedWarning` will be issued if the specified
 9692            transition is not traversable given the current game state
 9693            (but in that last case the step will still be taken).
 9694        - By default, the decision type for the new step will be
 9695            'active', but a `decisionType` value can be specified to
 9696            override that.
 9697        - By default, the 'mostLikely' `ChallengePolicy` will be used to
 9698            resolve challenges in the consequence of the transition
 9699            taken, but an alternate policy can be supplied using the
 9700            `challengePolicy` argument.
 9701        """
 9702        now = self.getSituation()
 9703
 9704        transitionName, outcomes = base.nameAndOutcomes(transition)
 9705
 9706        # Deduce transition details from the name + optional specifiers
 9707        (
 9708            using,
 9709            fromID,
 9710            destID,
 9711            whichFocus
 9712        ) = self.deduceTransitionDetailsAtStep(
 9713            -1,
 9714            transitionName,
 9715            fromDecision,
 9716            whichFocus,
 9717            inCommon
 9718        )
 9719
 9720        # Issue a warning if the destination name is already in use
 9721        if destination is not None:
 9722            if isinstance(destination, base.DecisionName):
 9723                try:
 9724                    existingID = now.graph.resolveDecision(destination)
 9725                    collision = existingID != destID
 9726                except MissingDecisionError:
 9727                    collision = False
 9728                except AmbiguousDecisionSpecifierError:
 9729                    collision = True
 9730
 9731                if collision and WARN_OF_NAME_COLLISIONS:
 9732                    warnings.warn(
 9733                        (
 9734                            f"The destination name {repr(destination)} is"
 9735                            f" already in use when exploring transition"
 9736                            f" {repr(transition)} from decision"
 9737                            f" {now.graph.identityOf(fromID)} at step"
 9738                            f" {len(self) - 1}."
 9739                        ),
 9740                        DecisionCollisionWarning
 9741                    )
 9742
 9743        # TODO: Different terminology for "exploration state above
 9744        # noticed" vs. "DG thinks it's been visited"...
 9745        if (
 9746            self.hasBeenVisited(destID)
 9747        ):
 9748            raise ExplorationStatusError(
 9749                f"Cannot explore to decision"
 9750                f" {now.graph.identityOf(destID)} because it has"
 9751                f" already been visited. Use returnTo instead of"
 9752                f" explore when discovering a connection back to a"
 9753                f" previously-explored decision."
 9754            )
 9755
 9756        if (
 9757            isinstance(destination, base.DecisionID)
 9758        and self.hasBeenVisited(destination)
 9759        ):
 9760            raise ExplorationStatusError(
 9761                f"Cannot explore to decision"
 9762                f" {now.graph.identityOf(destination)} because it has"
 9763                f" already been visited. Use returnTo instead of"
 9764                f" explore when discovering a connection back to a"
 9765                f" previously-explored decision."
 9766            )
 9767
 9768        actionTaken: base.ExplorationAction = (
 9769            'explore',
 9770            using,
 9771            fromID,
 9772            (transitionName, outcomes),
 9773            destination,
 9774            reciprocal,
 9775            zone
 9776        )
 9777        if whichFocus is not None:
 9778            # A move-from-specific-focal-point action
 9779            actionTaken = (
 9780                'explore',
 9781                whichFocus,
 9782                (transitionName, outcomes),
 9783                destination,
 9784                reciprocal,
 9785                zone
 9786            )
 9787
 9788        # Advance the situation, applying transition effects and
 9789        # updating the destination decision.
 9790        _, finalDest = self.advanceSituation(
 9791            actionTaken,
 9792            decisionType,
 9793            challengePolicy
 9794        )
 9795
 9796        # TODO: Is this assertion always valid?
 9797        assert len(finalDest) == 1
 9798        return next(x for x in finalDest)
 9799
 9800    def returnTo(
 9801        self,
 9802        transition: base.AnyTransition,
 9803        destination: base.AnyDecisionSpecifier,
 9804        reciprocal: Optional[base.Transition] = None,
 9805        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9806        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9807        inCommon: Union[bool, Literal["auto"]] = "auto",
 9808        decisionType: base.DecisionType = "active",
 9809        challengePolicy: base.ChallengePolicy = "specified"
 9810    ) -> base.DecisionID:
 9811        """
 9812        Adds a new graph to the exploration that replaces the given
 9813        transition at the current position (which must lead to an unknown
 9814        node, or a `MissingDecisionError` will result). The new
 9815        transition will connect back to the specified destination, which
 9816        must already exist (or a different `ValueError` will be raised).
 9817        Returns the decision ID for the destination reached.
 9818
 9819        Deduces transition details using the optional `fromDecision`,
 9820        `whichFocus`, and `inCommon` arguments in addition to the
 9821        `transition` value; see `deduceTransitionDetailsAtStep`.
 9822
 9823        If a `reciprocal` transition is specified, that transition must
 9824        either not already exist in the destination decision or lead to
 9825        an unknown region; it will be replaced (or added) as an edge
 9826        leading back to the current position.
 9827
 9828        The `decisionType` and `challengePolicy` optional arguments are
 9829        used for `advanceSituation`.
 9830
 9831        A `TransitionBlockedWarning` will be issued if the requirements
 9832        for the transition are not met, but the step will still be taken.
 9833        Raises a `MissingDecisionError` if there is no current
 9834        transition.
 9835        """
 9836        now = self.getSituation()
 9837
 9838        transitionName, outcomes = base.nameAndOutcomes(transition)
 9839
 9840        # Deduce transition details from the name + optional specifiers
 9841        (
 9842            using,
 9843            fromID,
 9844            destID,
 9845            whichFocus
 9846        ) = self.deduceTransitionDetailsAtStep(
 9847            -1,
 9848            transitionName,
 9849            fromDecision,
 9850            whichFocus,
 9851            inCommon
 9852        )
 9853
 9854        # Replace with connection to existing destination
 9855        destID = now.graph.resolveDecision(destination)
 9856        if not self.hasBeenVisited(destID):
 9857            raise ExplorationStatusError(
 9858                f"Cannot return to decision"
 9859                f" {now.graph.identityOf(destID)} because it has NOT"
 9860                f" already been at least partially explored. Use"
 9861                f" explore instead of returnTo when discovering a"
 9862                f" connection to a previously-unexplored decision."
 9863            )
 9864
 9865        now.graph.replaceUnconfirmed(
 9866            fromID,
 9867            transitionName,
 9868            destID,
 9869            reciprocal
 9870        )
 9871
 9872        # A move-from-decision action
 9873        actionTaken: base.ExplorationAction = (
 9874            'take',
 9875            using,
 9876            fromID,
 9877            (transitionName, outcomes)
 9878        )
 9879        if whichFocus is not None:
 9880            # A move-from-specific-focal-point action
 9881            actionTaken = ('take', whichFocus, (transitionName, outcomes))
 9882
 9883        # Next, advance the situation, applying transition effects
 9884        _, finalDest = self.advanceSituation(
 9885            actionTaken,
 9886            decisionType,
 9887            challengePolicy
 9888        )
 9889
 9890        assert len(finalDest) == 1
 9891        return next(x for x in finalDest)
 9892
 9893    def takeAction(
 9894        self,
 9895        action: base.AnyTransition,
 9896        requires: Optional[base.Requirement] = None,
 9897        consequence: Optional[base.Consequence] = None,
 9898        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9899        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9900        inCommon: Union[bool, Literal["auto"]] = "auto",
 9901        decisionType: base.DecisionType = "active",
 9902        challengePolicy: base.ChallengePolicy = "specified"
 9903    ) -> base.DecisionID:
 9904        """
 9905        Adds a new graph to the exploration based on taking the given
 9906        action, which must be a self-transition in the graph. If the
 9907        action does not already exist in the graph, it will be created.
 9908        Either way if requirements and/or a consequence are supplied,
 9909        the requirements and consequence of the action will be updated
 9910        to match them, and those are the requirements/consequence that
 9911        will count.
 9912
 9913        Returns the decision ID for the decision reached, which normally
 9914        is the same action you were just at, but which might be altered
 9915        by goto, bounce, and/or follow effects.
 9916
 9917        Issues a `TransitionBlockedWarning` if the current game state
 9918        doesn't satisfy the requirements for the action.
 9919
 9920        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
 9921        used for `deduceTransitionDetailsAtStep`, while `decisionType`
 9922        and `challengePolicy` are used for `advanceSituation`.
 9923
 9924        When an action is being created, `fromDecision` (or
 9925        `whichFocus`) must be specified, since the source decision won't
 9926        be deducible from the transition name. Note that if a transition
 9927        with the given name exists from *any* active decision, it will
 9928        be used instead of creating a new action (possibly resulting in
 9929        an error if it's not a self-loop transition). Also, you may get
 9930        an `AmbiguousTransitionError` if several transitions with that
 9931        name exist; in that case use `fromDecision` and/or `whichFocus`
 9932        to disambiguate.
 9933        """
 9934        now = self.getSituation()
 9935        graph = now.graph
 9936
 9937        actionName, outcomes = base.nameAndOutcomes(action)
 9938
 9939        try:
 9940            (
 9941                using,
 9942                fromID,
 9943                destID,
 9944                whichFocus
 9945            ) = self.deduceTransitionDetailsAtStep(
 9946                -1,
 9947                actionName,
 9948                fromDecision,
 9949                whichFocus,
 9950                inCommon
 9951            )
 9952
 9953            if destID != fromID:
 9954                raise ValueError(
 9955                    f"Cannot take action {repr(action)} because it's a"
 9956                    f" transition to another decision, not an action"
 9957                    f" (use explore, returnTo, and/or retrace instead)."
 9958                )
 9959
 9960        except MissingTransitionError:
 9961            using = 'active'
 9962            if inCommon is True:
 9963                using = 'common'
 9964
 9965            if fromDecision is not None:
 9966                fromID = graph.resolveDecision(fromDecision)
 9967            elif whichFocus is not None:
 9968                maybeFromID = base.resolvePosition(now, whichFocus)
 9969                if maybeFromID is None:
 9970                    raise MissingDecisionError(
 9971                        f"Focal point {repr(whichFocus)} was specified"
 9972                        f" in takeAction but that focal point doesn't"
 9973                        f" have a position."
 9974                    )
 9975                else:
 9976                    fromID = maybeFromID
 9977            else:
 9978                raise AmbiguousTransitionError(
 9979                    f"Taking action {repr(action)} is ambiguous because"
 9980                    f" the source decision has not been specified via"
 9981                    f" either fromDecision or whichFocus, and we"
 9982                    f" couldn't find an existing action with that name."
 9983                )
 9984
 9985            # Since the action doesn't exist, add it:
 9986            graph.addAction(fromID, actionName, requires, consequence)
 9987
 9988        # Update the transition requirement/consequence if requested
 9989        # (before the action is taken)
 9990        if requires is not None:
 9991            graph.setTransitionRequirement(fromID, actionName, requires)
 9992        if consequence is not None:
 9993            graph.setConsequence(fromID, actionName, consequence)
 9994
 9995        # A move-from-decision action
 9996        actionTaken: base.ExplorationAction = (
 9997            'take',
 9998            using,
 9999            fromID,
10000            (actionName, outcomes)
10001        )
10002        if whichFocus is not None:
10003            # A move-from-specific-focal-point action
10004            actionTaken = ('take', whichFocus, (actionName, outcomes))
10005
10006        _, finalDest = self.advanceSituation(
10007            actionTaken,
10008            decisionType,
10009            challengePolicy
10010        )
10011
10012        assert len(finalDest) in (0, 1)
10013        if len(finalDest) == 1:
10014            return next(x for x in finalDest)
10015        else:
10016            return fromID
10017
10018    def retrace(
10019        self,
10020        transition: base.AnyTransition,
10021        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10022        whichFocus: Optional[base.FocalPointSpecifier] = None,
10023        inCommon: Union[bool, Literal["auto"]] = "auto",
10024        decisionType: base.DecisionType = "active",
10025        challengePolicy: base.ChallengePolicy = "specified"
10026    ) -> base.DecisionID:
10027        """
10028        Adds a new graph to the exploration based on taking the given
10029        transition, which must already exist and which must not lead to
10030        an unknown region. Returns the ID of the destination decision,
10031        accounting for goto, bounce, and/or follow effects.
10032
10033        Issues a `TransitionBlockedWarning` if the current game state
10034        doesn't satisfy the requirements for the transition.
10035
10036        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
10037        used for `deduceTransitionDetailsAtStep`, while `decisionType`
10038        and `challengePolicy` are used for `advanceSituation`.
10039        """
10040        now = self.getSituation()
10041
10042        transitionName, outcomes = base.nameAndOutcomes(transition)
10043
10044        (
10045            using,
10046            fromID,
10047            destID,
10048            whichFocus
10049        ) = self.deduceTransitionDetailsAtStep(
10050            -1,
10051            transitionName,
10052            fromDecision,
10053            whichFocus,
10054            inCommon
10055        )
10056
10057        visited = self.hasBeenVisited(destID)
10058        confirmed = now.graph.isConfirmed(destID)
10059        if not confirmed:
10060            raise ExplorationStatusError(
10061                f"Cannot retrace transition {transition!r} from"
10062                f" decision {now.graph.identityOf(fromID)} because it"
10063                f" leads to an unconfirmed decision.\nUse"
10064                f" `DiscreteExploration.explore` and provide"
10065                f" destination decision details instead."
10066            )
10067        if not visited:
10068            raise ExplorationStatusError(
10069                f"Cannot retrace transition {transition!r} from"
10070                f" decision {now.graph.identityOf(fromID)} because it"
10071                f" leads to an unvisited decision.\nUse"
10072                f" `DiscreteExploration.explore` and provide"
10073                f" destination decision details instead."
10074            )
10075
10076        # A move-from-decision action
10077        actionTaken: base.ExplorationAction = (
10078            'take',
10079            using,
10080            fromID,
10081            (transitionName, outcomes)
10082        )
10083        if whichFocus is not None:
10084            # A move-from-specific-focal-point action
10085            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10086
10087        _, finalDest = self.advanceSituation(
10088            actionTaken,
10089            decisionType,
10090        challengePolicy
10091    )
10092
10093        assert len(finalDest) == 1
10094        return next(x for x in finalDest)
10095
10096    def warp(
10097        self,
10098        destination: base.AnyDecisionSpecifier,
10099        consequence: Optional[base.Consequence] = None,
10100        domain: Optional[base.Domain] = None,
10101        zone: Union[
10102            base.Zone,
10103            type[base.DefaultZone],
10104            None
10105        ] = base.DefaultZone,
10106        whichFocus: Optional[base.FocalPointSpecifier] = None,
10107        inCommon: Union[bool] = False,
10108        decisionType: base.DecisionType = "active",
10109        challengePolicy: base.ChallengePolicy = "specified"
10110    ) -> base.DecisionID:
10111        """
10112        Adds a new graph to the exploration that's a copy of the current
10113        graph, with the position updated to be at the destination without
10114        actually creating a transition from the old position to the new
10115        one. Returns the ID of the decision warped to (accounting for
10116        any goto or follow effects triggered).
10117
10118        Any provided consequences are applied, but are not associated
10119        with any transition (so any delays and charges are ignored, and
10120        'bounce' effects don't actually cancel the warp). 'goto' or
10121        'follow' effects might change the warp destination; 'follow'
10122        effects take the original destination as their starting point.
10123        Any mechanisms mentioned in extra consequences will be found
10124        based on the destination. Outcomes in supplied challenges should
10125        be pre-specified, or else they will be resolved with the
10126        `challengePolicy`.
10127
10128        `whichFocus` may be specified when the destination domain's
10129        focalization is 'plural' but for 'singular' or 'spreading'
10130        destination domains it is not allowed. `inCommon` determines
10131        whether the common or the active focal context is updated
10132        (default is to update the active context). The `decisionType`
10133        and `challengePolicy` are used for `advanceSituation`.
10134
10135        - If the destination did not already exist, it will be created.
10136            Initially, it will be disconnected from all other decisions.
10137            In this case, the `domain` value can be used to put it in a
10138            non-default domain.
10139        - The position is set to the specified destination, and if a
10140            `consequence` is specified it is applied. Note that
10141            'deactivate' effects are NOT allowed, and 'edit' effects
10142            must establish their own transition target because there is
10143            no transition that the effects are being applied to.
10144        - If the destination had been unexplored, its exploration status
10145            will be set to 'exploring'.
10146        - If a `zone` is specified, the destination will be added to that
10147            zone (even if the destination already existed) and that zone
10148            will be created (as a level-0 zone) if need be. If `zone` is
10149            set to `None`, then no zone will be applied. If `zone` is
10150            left as the default (`DefaultZone`) and the focalization of
10151            the destination domain is 'singular' or 'plural' and the
10152            destination is newly created and there is an origin and the
10153            origin is in the same domain as the destination, then the
10154            destination will be added to all zones that the origin was a
10155            part of if the destination is newly created, but otherwise
10156            the destination will not be added to any zones. If the
10157            specified zone has to be created and there's an origin
10158            decision, it will be added as a sub-zone to all parents of
10159            zones directly containing the origin, as long as the origin
10160            is in the same domain as the destination.
10161        """
10162        now = self.getSituation()
10163        graph = now.graph
10164
10165        fromID: Optional[base.DecisionID]
10166
10167        new = False
10168        try:
10169            destID = graph.resolveDecision(destination)
10170        except MissingDecisionError:
10171            if isinstance(destination, tuple):
10172                # just the name; ignore zone/domain
10173                destination = destination[-1]
10174
10175            if not isinstance(destination, base.DecisionName):
10176                raise TypeError(
10177                    f"Warp destination {repr(destination)} does not"
10178                    f" exist, and cannot be created as it is not a"
10179                    f" decision name."
10180                )
10181            destID = graph.addDecision(destination, domain)
10182            graph.tagDecision(destID, 'unconfirmed')
10183            self.setExplorationStatus(destID, 'unknown')
10184            new = True
10185
10186        using: base.ContextSpecifier
10187        if inCommon:
10188            targetContext = self.getCommonContext()
10189            using = "common"
10190        else:
10191            targetContext = self.getActiveContext()
10192            using = "active"
10193
10194        destDomain = graph.domainFor(destID)
10195        targetFocalization = base.getDomainFocalization(
10196            targetContext,
10197            destDomain
10198        )
10199        if targetFocalization == 'singular':
10200            targetActive = targetContext['activeDecisions']
10201            if destDomain in targetActive:
10202                fromID = cast(
10203                    base.DecisionID,
10204                    targetContext['activeDecisions'][destDomain]
10205                )
10206            else:
10207                fromID = None
10208        elif targetFocalization == 'plural':
10209            if whichFocus is None:
10210                raise AmbiguousTransitionError(
10211                    f"Warping to {repr(destination)} is ambiguous"
10212                    f" becuase domain {repr(destDomain)} has plural"
10213                    f" focalization, and no whichFocus value was"
10214                    f" specified."
10215                )
10216
10217            fromID = base.resolvePosition(
10218                self.getSituation(),
10219                whichFocus
10220            )
10221        else:
10222            fromID = None
10223
10224        # Handle zones
10225        if zone is base.DefaultZone:
10226            if (
10227                new
10228            and fromID is not None
10229            and graph.domainFor(fromID) == destDomain
10230            ):
10231                for prevZone in graph.zoneParents(fromID):
10232                    graph.addDecisionToZone(destination, prevZone)
10233            # Otherwise don't update zones
10234        elif zone is not None:
10235            # Newness is ignored when a zone is specified
10236            zone = cast(base.Zone, zone)
10237            # Create the zone at level 0 if it didn't already exist
10238            if graph.getZoneInfo(zone) is None:
10239                graph.createZone(zone, 0)
10240                # Add the newly created zone to each 2nd-level parent of
10241                # the previous decision if there is one and it's in the
10242                # same domain
10243                if (
10244                    fromID is not None
10245                and graph.domainFor(fromID) == destDomain
10246                ):
10247                    for prevZone in graph.zoneParents(fromID):
10248                        for prevUpper in graph.zoneParents(prevZone):
10249                            graph.addZoneToZone(zone, prevUpper)
10250            # Finally add the destination to the (maybe new) zone
10251            graph.addDecisionToZone(destID, zone)
10252        # else don't touch zones
10253
10254        # Encode the action taken
10255        actionTaken: base.ExplorationAction
10256        if whichFocus is None:
10257            actionTaken = (
10258                'warp',
10259                using,
10260                destID
10261            )
10262        else:
10263            actionTaken = (
10264                'warp',
10265                whichFocus,
10266                destID
10267            )
10268
10269        # Advance the situation
10270        _, finalDests = self.advanceSituation(
10271            actionTaken,
10272            decisionType,
10273            challengePolicy
10274        )
10275        now = self.getSituation()  # updating just in case
10276
10277        assert len(finalDests) == 1
10278        finalDest = next(x for x in finalDests)
10279
10280        # Apply additional consequences:
10281        if consequence is not None:
10282            altDest = self.applyExtraneousConsequence(
10283                consequence,
10284                where=(destID, None),
10285                # TODO: Mechanism search from both ends?
10286                moveWhich=(
10287                    whichFocus[-1]
10288                    if whichFocus is not None
10289                    else None
10290                )
10291            )
10292            if altDest is not None:
10293                finalDest = altDest
10294            now = self.getSituation()  # updating just in case
10295
10296        return finalDest
10297
10298    def wait(
10299        self,
10300        consequence: Optional[base.Consequence] = None,
10301        decisionType: base.DecisionType = "active",
10302        challengePolicy: base.ChallengePolicy = "specified"
10303    ) -> Optional[base.DecisionID]:
10304        """
10305        Adds a wait step. If a consequence is specified, it is applied,
10306        although it will not have any position/transition information
10307        available during resolution/application.
10308
10309        A decision type other than "active" and/or a challenge policy
10310        other than "specified" can be included (see `advanceSituation`).
10311
10312        The "pending" decision type may not be used, a `ValueError` will
10313        result. This allows None as the action for waiting while
10314        preserving the pending/None type/action combination for
10315        unresolved situations.
10316
10317        If a goto or follow effect in the applied consequence implies a
10318        position update, this will return the new destination ID;
10319        otherwise it will return `None`. Triggering a 'bounce' effect
10320        will be an error, because there is no position information for
10321        the effect.
10322        """
10323        if decisionType == "pending":
10324            raise ValueError(
10325                "The 'pending' decision type may not be used for"
10326                " wait actions."
10327            )
10328        self.advanceSituation(('noAction',), decisionType, challengePolicy)
10329        now = self.getSituation()
10330        if consequence is not None:
10331            if challengePolicy != "specified":
10332                base.resetChallengeOutcomes(consequence)
10333            observed = base.observeChallengeOutcomes(
10334                base.RequirementContext(
10335                    state=now.state,
10336                    graph=now.graph,
10337                    searchFrom=set()
10338                ),
10339                consequence,
10340                location=None,  # No position info
10341                policy=challengePolicy,
10342                knownOutcomes=None  # bake outcomes into the consequence
10343            )
10344            # No location information since we might have multiple
10345            # active decisions and there's no indication of which one
10346            # we're "waiting at."
10347            finalDest = self.applyExtraneousConsequence(observed)
10348            now = self.getSituation()  # updating just in case
10349
10350            return finalDest
10351        else:
10352            return None
10353
10354    def revert(
10355        self,
10356        slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT,
10357        aspects: Optional[Set[str]] = None,
10358        decisionType: base.DecisionType = "active"
10359    ) -> None:
10360        """
10361        Reverts the game state to a previously-saved game state (saved
10362        via a 'save' effect). The save slot name and set of aspects to
10363        revert are required. By default, all aspects except the graph
10364        are reverted.
10365        """
10366        if aspects is None:
10367            aspects = set()
10368
10369        action: base.ExplorationAction = ("revertTo", slot, aspects)
10370
10371        self.advanceSituation(action, decisionType)
10372
10373    def observeAll(
10374        self,
10375        where: base.AnyDecisionSpecifier,
10376        *transitions: Union[
10377            base.Transition,
10378            Tuple[base.Transition, base.AnyDecisionSpecifier],
10379            Tuple[
10380                base.Transition,
10381                base.AnyDecisionSpecifier,
10382                base.Transition
10383            ]
10384        ]
10385    ) -> List[base.DecisionID]:
10386        """
10387        Observes one or more new transitions, applying changes to the
10388        current graph. The transitions can be specified in one of three
10389        ways:
10390
10391        1. A transition name. The transition will be created and will
10392            point to a new unexplored node.
10393        2. A pair containing a transition name and a destination
10394            specifier. If the destination does not exist it will be
10395            created as an unexplored node, although in that case the
10396            decision specifier may not be an ID.
10397        3. A triple containing a transition name, a destination
10398            specifier, and a reciprocal name. Works the same as the pair
10399            case but also specifies the name for the reciprocal
10400            transition.
10401
10402        The new transitions are outgoing from specified decision.
10403
10404        Yields the ID of each decision connected to, whether those are
10405        new or existing decisions.
10406        """
10407        now = self.getSituation()
10408        fromID = now.graph.resolveDecision(where)
10409        result = []
10410        for entry in transitions:
10411            if isinstance(entry, base.Transition):
10412                result.append(self.observe(fromID, entry))
10413            else:
10414                result.append(self.observe(fromID, *entry))
10415        return result
10416
10417    def observe(
10418        self,
10419        where: base.AnyDecisionSpecifier,
10420        transition: base.Transition,
10421        destination: Optional[base.AnyDecisionSpecifier] = None,
10422        reciprocal: Optional[base.Transition] = None
10423    ) -> base.DecisionID:
10424        """
10425        Observes a single new outgoing transition from the specified
10426        decision. If specified the transition connects to a specific
10427        destination and/or has a specific reciprocal. The specified
10428        destination will be created if it doesn't exist, or where no
10429        destination is specified, a new unexplored decision will be
10430        added. The ID of the decision connected to is returned.
10431
10432        Sets the exploration status of the observed destination to
10433        "noticed" if a destination is specified and needs to be created
10434        (but not when no destination is specified).
10435
10436        For example:
10437
10438        >>> e = DiscreteExploration()
10439        >>> e.start('start')
10440        0
10441        >>> e.observe('start', 'up')
10442        1
10443        >>> g = e.getSituation().graph
10444        >>> g.destinationsFrom('start')
10445        {'up': 1}
10446        >>> e.getExplorationStatus(1)  # not given a name: assumed unknown
10447        'unknown'
10448        >>> e.observe('start', 'left', 'A')
10449        2
10450        >>> g.destinationsFrom('start')
10451        {'up': 1, 'left': 2}
10452        >>> g.nameFor(2)
10453        'A'
10454        >>> e.getExplorationStatus(2)  # given a name: noticed
10455        'noticed'
10456        >>> e.observe('start', 'up2', 1)
10457        1
10458        >>> g.destinationsFrom('start')
10459        {'up': 1, 'left': 2, 'up2': 1}
10460        >>> e.getExplorationStatus(1)  # existing decision: status unchanged
10461        'unknown'
10462        >>> e.observe('start', 'right', 'B', 'left')
10463        3
10464        >>> g.destinationsFrom('start')
10465        {'up': 1, 'left': 2, 'up2': 1, 'right': 3}
10466        >>> g.nameFor(3)
10467        'B'
10468        >>> e.getExplorationStatus(3)  # new + name -> noticed
10469        'noticed'
10470        >>> e.observe('start', 'right')  # repeat transition name
10471        Traceback (most recent call last):
10472        ...
10473        exploration.core.TransitionCollisionError...
10474        >>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
10475        Traceback (most recent call last):
10476        ...
10477        exploration.core.TransitionCollisionError...
10478        >>> g = e.getSituation().graph
10479        >>> g.createZone('Z', 0)
10480        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
10481 annotations=[])
10482        >>> g.addDecisionToZone('start', 'Z')
10483        >>> e.observe('start', 'down', 'C', 'up')
10484        4
10485        >>> g.destinationsFrom('start')
10486        {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
10487        >>> g.identityOf('C')
10488        '4 (C)'
10489        >>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
10490        set()
10491        >>> e.observe(
10492        ...     'C',
10493        ...     'right',
10494        ...     base.DecisionSpecifier('main', 'Z2', 'D'),
10495        ... )  # creates zone
10496        5
10497        >>> g.destinationsFrom('C')
10498        {'up': 0, 'right': 5}
10499        >>> g.destinationsFrom('D')  # default reciprocal name
10500        {'return': 4}
10501        >>> g.identityOf('D')
10502        '5 (Z2::D)'
10503        >>> g.zoneParents(5)
10504        {'Z2'}
10505        """
10506        now = self.getSituation()
10507        fromID = now.graph.resolveDecision(where)
10508
10509        kwargs: Dict[
10510            str,
10511            Union[base.Transition, base.DecisionName, None]
10512        ] = {}
10513        if reciprocal is not None:
10514            kwargs['reciprocal'] = reciprocal
10515
10516        if destination is not None:
10517            try:
10518                destID = now.graph.resolveDecision(destination)
10519                now.graph.addTransition(
10520                    fromID,
10521                    transition,
10522                    destID,
10523                    reciprocal
10524                )
10525                return destID
10526            except MissingDecisionError:
10527                if isinstance(destination, base.DecisionSpecifier):
10528                    kwargs['toDomain'] = destination.domain
10529                    kwargs['placeInZone'] = destination.zone
10530                    kwargs['destinationName'] = destination.name
10531                elif isinstance(destination, base.DecisionName):
10532                    kwargs['destinationName'] = destination
10533                else:
10534                    assert isinstance(destination, base.DecisionID)
10535                    # We got to except by failing to resolve, so it's an
10536                    # invalid ID
10537                    raise
10538
10539        result = now.graph.addUnexploredEdge(
10540            fromID,
10541            transition,
10542            **kwargs  # type: ignore [arg-type]
10543        )
10544        if 'destinationName' in kwargs:
10545            self.setExplorationStatus(result, 'noticed', upgradeOnly=True)
10546        return result
10547
10548    def observeMechanisms(
10549        self,
10550        where: Optional[base.AnyDecisionSpecifier],
10551        *mechanisms: Union[
10552            base.MechanismName,
10553            Tuple[base.MechanismName, base.MechanismState]
10554        ]
10555    ) -> List[base.MechanismID]:
10556        """
10557        Adds one or more mechanisms to the exploration's current graph,
10558        located at the specified decision. Global mechanisms can be
10559        added by using `None` for the location. Mechanisms are named, or
10560        a (name, state) tuple can be used to set them into a specific
10561        state. Mechanisms not set to a state will be in the
10562        `base.DEFAULT_MECHANISM_STATE`.
10563        """
10564        now = self.getSituation()
10565        result = []
10566        for mSpec in mechanisms:
10567            setState = None
10568            if isinstance(mSpec, base.MechanismName):
10569                result.append(now.graph.addMechanism(mSpec, where))
10570            elif (
10571                isinstance(mSpec, tuple)
10572            and len(mSpec) == 2
10573            and isinstance(mSpec[0], base.MechanismName)
10574            and isinstance(mSpec[1], base.MechanismState)
10575            ):
10576                result.append(now.graph.addMechanism(mSpec[0], where))
10577                setState = mSpec[1]
10578            else:
10579                raise TypeError(
10580                    f"Invalid mechanism: {repr(mSpec)} (must be a"
10581                    f" mechanism name or a (name, state) tuple."
10582                )
10583
10584            if setState:
10585                self.setMechanismStateNow(result[-1], setState)
10586
10587        return result
10588
10589    def reZone(
10590        self,
10591        zone: base.Zone,
10592        where: base.AnyDecisionSpecifier,
10593        replace: Union[base.Zone, int] = 0
10594    ) -> None:
10595        """
10596        Alters the current graph without adding a new exploration step.
10597
10598        Calls `DecisionGraph.replaceZonesInHierarchy` targeting the
10599        specified decision. Note that per the logic of that method, ALL
10600        zones at the specified hierarchy level are replaced, even if a
10601        specific zone to replace is specified here.
10602
10603        TODO: not that?
10604
10605        The level value is either specified via `replace` (default 0) or
10606        deduced from the zone provided as the `replace` value using
10607        `DecisionGraph.zoneHierarchyLevel`.
10608        """
10609        now = self.getSituation()
10610
10611        if isinstance(replace, int):
10612            level = replace
10613        else:
10614            level = now.graph.zoneHierarchyLevel(replace)
10615
10616        now.graph.replaceZonesInHierarchy(where, zone, level)
10617
10618    def runCommand(
10619        self,
10620        command: commands.Command,
10621        scope: Optional[commands.Scope] = None,
10622        line: int = -1
10623    ) -> commands.CommandResult:
10624        """
10625        Runs a single `Command` applying effects to the exploration, its
10626        current graph, and the provided execution context, and returning
10627        a command result, which contains the modified scope plus
10628        optional skip and label values (see `CommandResult`). This
10629        function also directly modifies the scope you give it. Variable
10630        references in the command are resolved via entries in the
10631        provided scope. If no scope is given, an empty one is created.
10632
10633        A line number may be supplied for use in error messages; if left
10634        out line -1 will be used.
10635
10636        Raises an error if the command is invalid.
10637
10638        For commands that establish a value as the 'current value', that
10639        value will be stored in the '_' variable. When this happens, the
10640        old contents of '_' are stored in '__' first, and the old
10641        contents of '__' are discarded. Note that non-automatic
10642        assignment to '_' does not move the old value to '__'.
10643        """
10644        try:
10645            if scope is None:
10646                scope = {}
10647
10648            skip: Union[int, str, None] = None
10649            label: Optional[str] = None
10650
10651            if command.command == 'val':
10652                command = cast(commands.LiteralValue, command)
10653                result = commands.resolveValue(command.value, scope)
10654                commands.pushCurrentValue(scope, result)
10655
10656            elif command.command == 'empty':
10657                command = cast(commands.EstablishCollection, command)
10658                collection = commands.resolveVarName(command.collection, scope)
10659                commands.pushCurrentValue(
10660                    scope,
10661                    {
10662                        'list': [],
10663                        'tuple': (),
10664                        'set': set(),
10665                        'dict': {},
10666                    }[collection]
10667                )
10668
10669            elif command.command == 'append':
10670                command = cast(commands.AppendValue, command)
10671                target = scope['_']
10672                addIt = commands.resolveValue(command.value, scope)
10673                if isinstance(target, list):
10674                    target.append(addIt)
10675                elif isinstance(target, tuple):
10676                    scope['_'] = target + (addIt,)
10677                elif isinstance(target, set):
10678                    target.add(addIt)
10679                elif isinstance(target, dict):
10680                    raise TypeError(
10681                        "'append' command cannot be used with a"
10682                        " dictionary. Use 'set' instead."
10683                    )
10684                else:
10685                    raise TypeError(
10686                        f"Invalid current value for 'append' command."
10687                        f" The current value must be a list, tuple, or"
10688                        f" set, but it was a '{type(target).__name__}'."
10689                    )
10690
10691            elif command.command == 'set':
10692                command = cast(commands.SetValue, command)
10693                target = scope['_']
10694                where = commands.resolveValue(command.location, scope)
10695                what = commands.resolveValue(command.value, scope)
10696                if isinstance(target, list):
10697                    if not isinstance(where, int):
10698                        raise TypeError(
10699                            f"Cannot set item in list: index {where!r}"
10700                            f" is not an integer."
10701                        )
10702                    target[where] = what
10703                elif isinstance(target, tuple):
10704                    if not isinstance(where, int):
10705                        raise TypeError(
10706                            f"Cannot set item in tuple: index {where!r}"
10707                            f" is not an integer."
10708                        )
10709                    if not (
10710                        0 <= where < len(target)
10711                    or -1 >= where >= -len(target)
10712                    ):
10713                        raise IndexError(
10714                            f"Cannot set item in tuple at index"
10715                            f" {where}: Tuple has length {len(target)}."
10716                        )
10717                    scope['_'] = target[:where] + (what,) + target[where + 1:]
10718                elif isinstance(target, set):
10719                    if what:
10720                        target.add(where)
10721                    else:
10722                        try:
10723                            target.remove(where)
10724                        except KeyError:
10725                            pass
10726                elif isinstance(target, dict):
10727                    target[where] = what
10728
10729            elif command.command == 'pop':
10730                command = cast(commands.PopValue, command)
10731                target = scope['_']
10732                if isinstance(target, list):
10733                    result = target.pop()
10734                    commands.pushCurrentValue(scope, result)
10735                elif isinstance(target, tuple):
10736                    result = target[-1]
10737                    updated = target[:-1]
10738                    scope['__'] = updated
10739                    scope['_'] = result
10740                else:
10741                    raise TypeError(
10742                        f"Cannot 'pop' from a {type(target).__name__}"
10743                        f" (current value must be a list or tuple)."
10744                    )
10745
10746            elif command.command == 'get':
10747                command = cast(commands.GetValue, command)
10748                target = scope['_']
10749                where = commands.resolveValue(command.location, scope)
10750                if isinstance(target, list):
10751                    if not isinstance(where, int):
10752                        raise TypeError(
10753                            f"Cannot get item from list: index"
10754                            f" {where!r} is not an integer."
10755                        )
10756                elif isinstance(target, tuple):
10757                    if not isinstance(where, int):
10758                        raise TypeError(
10759                            f"Cannot get item from tuple: index"
10760                            f" {where!r} is not an integer."
10761                        )
10762                elif isinstance(target, set):
10763                    result = where in target
10764                    commands.pushCurrentValue(scope, result)
10765                elif isinstance(target, dict):
10766                    result = target[where]
10767                    commands.pushCurrentValue(scope, result)
10768                else:
10769                    result = getattr(target, where)
10770                    commands.pushCurrentValue(scope, result)
10771
10772            elif command.command == 'remove':
10773                command = cast(commands.RemoveValue, command)
10774                target = scope['_']
10775                where = commands.resolveValue(command.location, scope)
10776                if isinstance(target, (list, tuple)):
10777                    # this cast is not correct but suppresses warnings
10778                    # given insufficient narrowing by MyPy
10779                    target = cast(Tuple[Any, ...], target)
10780                    if not isinstance(where, int):
10781                        raise TypeError(
10782                            f"Cannot remove item from list or tuple:"
10783                            f" index {where!r} is not an integer."
10784                        )
10785                    scope['_'] = target[:where] + target[where + 1:]
10786                elif isinstance(target, set):
10787                    target.remove(where)
10788                elif isinstance(target, dict):
10789                    del target[where]
10790                else:
10791                    raise TypeError(
10792                        f"Cannot use 'remove' on a/an"
10793                        f" {type(target).__name__}."
10794                    )
10795
10796            elif command.command == 'op':
10797                command = cast(commands.ApplyOperator, command)
10798                left = commands.resolveValue(command.left, scope)
10799                right = commands.resolveValue(command.right, scope)
10800                op = command.op
10801                if op == '+':
10802                    result = left + right
10803                elif op == '-':
10804                    result = left - right
10805                elif op == '*':
10806                    result = left * right
10807                elif op == '/':
10808                    result = left / right
10809                elif op == '//':
10810                    result = left // right
10811                elif op == '**':
10812                    result = left ** right
10813                elif op == '%':
10814                    result = left % right
10815                elif op == '^':
10816                    result = left ^ right
10817                elif op == '|':
10818                    result = left | right
10819                elif op == '&':
10820                    result = left & right
10821                elif op == 'and':
10822                    result = left and right
10823                elif op == 'or':
10824                    result = left or right
10825                elif op == '<':
10826                    result = left < right
10827                elif op == '>':
10828                    result = left > right
10829                elif op == '<=':
10830                    result = left <= right
10831                elif op == '>=':
10832                    result = left >= right
10833                elif op == '==':
10834                    result = left == right
10835                elif op == 'is':
10836                    result = left is right
10837                else:
10838                    raise RuntimeError("Invalid operator '{op}'.")
10839
10840                commands.pushCurrentValue(scope, result)
10841
10842            elif command.command == 'unary':
10843                command = cast(commands.ApplyUnary, command)
10844                value = commands.resolveValue(command.value, scope)
10845                op = command.op
10846                if op == '-':
10847                    result = -value
10848                elif op == '~':
10849                    result = ~value
10850                elif op == 'not':
10851                    result = not value
10852
10853                commands.pushCurrentValue(scope, result)
10854
10855            elif command.command == 'assign':
10856                command = cast(commands.VariableAssignment, command)
10857                varname = commands.resolveVarName(command.varname, scope)
10858                value = commands.resolveValue(command.value, scope)
10859                scope[varname] = value
10860
10861            elif command.command == 'delete':
10862                command = cast(commands.VariableDeletion, command)
10863                varname = commands.resolveVarName(command.varname, scope)
10864                del scope[varname]
10865
10866            elif command.command == 'load':
10867                command = cast(commands.LoadVariable, command)
10868                varname = commands.resolveVarName(command.varname, scope)
10869                commands.pushCurrentValue(scope, scope[varname])
10870
10871            elif command.command == 'call':
10872                command = cast(commands.FunctionCall, command)
10873                function = command.function
10874                if function.startswith('$'):
10875                    function = commands.resolveValue(function, scope)
10876
10877                toCall: Callable
10878                args: Tuple[str, ...]
10879                kwargs: Dict[str, Any]
10880
10881                if command.target == 'builtin':
10882                    toCall = commands.COMMAND_BUILTINS[function]
10883                    args = (scope['_'],)
10884                    kwargs = {}
10885                    if toCall == round:
10886                        if 'ndigits' in scope:
10887                            kwargs['ndigits'] = scope['ndigits']
10888                    elif toCall == range and args[0] is None:
10889                        start = scope.get('start', 0)
10890                        stop = scope['stop']
10891                        step = scope.get('step', 1)
10892                        args = (start, stop, step)
10893
10894                else:
10895                    if command.target == 'stored':
10896                        toCall = function
10897                    elif command.target == 'graph':
10898                        toCall = getattr(self.getSituation().graph, function)
10899                    elif command.target == 'exploration':
10900                        toCall = getattr(self, function)
10901                    else:
10902                        raise TypeError(
10903                            f"Invalid call target '{command.target}'"
10904                            f" (must be one of 'builtin', 'stored',"
10905                            f" 'graph', or 'exploration'."
10906                        )
10907
10908                    # Fill in arguments via kwargs defined in scope
10909                    args = ()
10910                    kwargs = {}
10911                    signature = inspect.signature(toCall)
10912                    # TODO: Maybe try some type-checking here?
10913                    for argName, param in signature.parameters.items():
10914                        if param.kind == inspect.Parameter.VAR_POSITIONAL:
10915                            if argName in scope:
10916                                args = args + tuple(scope[argName])
10917                            # Else leave args as-is
10918                        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
10919                            # These must have a default
10920                            if argName in scope:
10921                                kwargs[argName] = scope[argName]
10922                        elif param.kind == inspect.Parameter.VAR_KEYWORD:
10923                            # treat as a dictionary
10924                            if argName in scope:
10925                                argsToUse = scope[argName]
10926                                if not isinstance(argsToUse, dict):
10927                                    raise TypeError(
10928                                        f"Variable '{argName}' must"
10929                                        f" hold a dictionary when"
10930                                        f" calling function"
10931                                        f" '{toCall.__name__} which"
10932                                        f" uses that argument as a"
10933                                        f" keyword catchall."
10934                                    )
10935                                kwargs.update(scope[argName])
10936                        else:  # a normal parameter
10937                            if argName in scope:
10938                                args = args + (scope[argName],)
10939                            elif param.default == inspect.Parameter.empty:
10940                                raise TypeError(
10941                                    f"No variable named '{argName}' has"
10942                                    f" been defined to supply the"
10943                                    f" required parameter with that"
10944                                    f" name for function"
10945                                    f" '{toCall.__name__}'."
10946                                )
10947
10948                result = toCall(*args, **kwargs)
10949                commands.pushCurrentValue(scope, result)
10950
10951            elif command.command == 'skip':
10952                command = cast(commands.SkipCommands, command)
10953                doIt = commands.resolveValue(command.condition, scope)
10954                if doIt:
10955                    skip = commands.resolveValue(command.amount, scope)
10956                    if not isinstance(skip, (int, str)):
10957                        raise TypeError(
10958                            f"Skip amount must be an integer or a label"
10959                            f" name (got {skip!r})."
10960                        )
10961
10962            elif command.command == 'label':
10963                command = cast(commands.Label, command)
10964                label = commands.resolveValue(command.name, scope)
10965                if not isinstance(label, str):
10966                    raise TypeError(
10967                        f"Label name must be a string (got {label!r})."
10968                    )
10969
10970            else:
10971                raise ValueError(
10972                    f"Invalid command type: {command.command!r}"
10973                )
10974        except ValueError as e:
10975            raise commands.CommandValueError(command, line, e)
10976        except TypeError as e:
10977            raise commands.CommandTypeError(command, line, e)
10978        except IndexError as e:
10979            raise commands.CommandIndexError(command, line, e)
10980        except KeyError as e:
10981            raise commands.CommandKeyError(command, line, e)
10982        except Exception as e:
10983            raise commands.CommandOtherError(command, line, e)
10984
10985        return (scope, skip, label)
10986
10987    def runCommandBlock(
10988        self,
10989        block: List[commands.Command],
10990        scope: Optional[commands.Scope] = None
10991    ) -> commands.Scope:
10992        """
10993        Runs a list of commands, using the given scope (or creating a new
10994        empty scope if none was provided). Returns the scope after
10995        running all of the commands, which may also edit the exploration
10996        and/or the current graph of course.
10997
10998        Note that if a skip command would skip past the end of the
10999        block, execution will end. If a skip command would skip before
11000        the beginning of the block, execution will start from the first
11001        command.
11002
11003        Example:
11004
11005        >>> e = DiscreteExploration()
11006        >>> scope = e.runCommandBlock([
11007        ...    commands.command('assign', 'decision', "'START'"),
11008        ...    commands.command('call', 'exploration', 'start'),
11009        ...    commands.command('assign', 'where', '$decision'),
11010        ...    commands.command('assign', 'transition', "'left'"),
11011        ...    commands.command('call', 'exploration', 'observe'),
11012        ...    commands.command('assign', 'transition', "'right'"),
11013        ...    commands.command('call', 'exploration', 'observe'),
11014        ...    commands.command('call', 'graph', 'destinationsFrom'),
11015        ...    commands.command('call', 'builtin', 'print'),
11016        ...    commands.command('assign', 'transition', "'right'"),
11017        ...    commands.command('assign', 'destination', "'EastRoom'"),
11018        ...    commands.command('call', 'exploration', 'explore'),
11019        ... ])
11020        {'left': 1, 'right': 2}
11021        >>> scope['decision']
11022        'START'
11023        >>> scope['where']
11024        'START'
11025        >>> scope['_']  # result of 'explore' call is dest ID
11026        2
11027        >>> scope['transition']
11028        'right'
11029        >>> scope['destination']
11030        'EastRoom'
11031        >>> g = e.getSituation().graph
11032        >>> len(e)
11033        3
11034        >>> len(g)
11035        3
11036        >>> g.namesListing(g)
11037        '  0 (START)\\n  1 (_u.0)\\n  2 (EastRoom)\\n'
11038        """
11039        if scope is None:
11040            scope = {}
11041
11042        labelPositions: Dict[str, List[int]] = {}
11043
11044        # Keep going until we've exhausted the commands list
11045        index = 0
11046        while index < len(block):
11047
11048            # Execute the next command
11049            scope, skip, label = self.runCommand(
11050                block[index],
11051                scope,
11052                index + 1
11053            )
11054
11055            # Increment our index, or apply a skip
11056            if skip is None:
11057                index = index + 1
11058
11059            elif isinstance(skip, int):  # Integer skip value
11060                if skip < 0:
11061                    index += skip
11062                    if index < 0:  # can't skip before the start
11063                        index = 0
11064                else:
11065                    index += skip + 1  # may end loop if we skip too far
11066
11067            else:  # must be a label name
11068                if skip in labelPositions:  # an established label
11069                    # We jump to the last previous index, or if there
11070                    # are none, to the first future index.
11071                    prevIndices = [
11072                        x
11073                        for x in labelPositions[skip]
11074                        if x < index
11075                    ]
11076                    futureIndices = [
11077                        x
11078                        for x in labelPositions[skip]
11079                        if x >= index
11080                    ]
11081                    if len(prevIndices) > 0:
11082                        index = max(prevIndices)
11083                    else:
11084                        index = min(futureIndices)
11085                else:  # must be a forward-reference
11086                    for future in range(index + 1, len(block)):
11087                        inspect = block[future]
11088                        if inspect.command == 'label':
11089                            inspect = cast(commands.Label, inspect)
11090                            if inspect.name == skip:
11091                                index = future
11092                                break
11093                    else:
11094                        raise KeyError(
11095                            f"Skip command indicated a jump to label"
11096                            f" {skip!r} but that label had not already"
11097                            f" been defined and there is no future"
11098                            f" label with that name either (future"
11099                            f" labels based on variables cannot be"
11100                            f" skipped to from above as their names"
11101                            f" are not known yet)."
11102                        )
11103
11104            # If there's a label, record it
11105            if label is not None:
11106                labelPositions.setdefault(label, []).append(index)
11107
11108            # And now the while loop continues, or ends if we're at the
11109            # end of the commands list.
11110
11111        # Return the scope object.
11112        return scope
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 identityOf(
 938        self,
 939        decision: Optional[base.AnyDecisionSpecifier],
 940        includeZones: bool = True,
 941        alwaysDomain: Optional[bool] = None
 942    ) -> str:
 943        """
 944        Returns a string containing the given decision ID and the name
 945        for that decision in parentheses afterwards. If the value
 946        provided 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"{dID} ({dSpec}{zSpec}{self.nameFor(dID)})"
 984
 985    def namesListing(
 986        self,
 987        decisions: Collection[base.DecisionID],
 988        includeZones: bool = True,
 989        indent: int = 2
 990    ) -> str:
 991        """
 992        Returns a multi-line string containing an indented listing of
 993        the provided decision IDs with their names in parentheses after
 994        each. Useful for debugging & error messages.
 995
 996        Includes level-0 zones where applicable, with a zone separator
 997        before the decision, unless `includeZones` is set to False. Where
 998        there are multiple level-0 zones, they're listed together in
 999        brackets.
1000
1001        Uses the string '(none)' when there are no decisions are in the
1002        list.
1003
1004        Set `indent` to something other than 2 to control how much
1005        indentation is added.
1006
1007        For example:
1008
1009        >>> g = DecisionGraph()
1010        >>> g.addDecision('A')
1011        0
1012        >>> g.addDecision('B')
1013        1
1014        >>> g.addDecision('C')
1015        2
1016        >>> g.namesListing(['A', 'C', 'B'])
1017        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
1018        >>> g.namesListing([])
1019        '  (none)\\n'
1020        >>> g.createZone('zone', 0)
1021        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1022 annotations=[])
1023        >>> g.createZone('zone2', 0)
1024        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1025 annotations=[])
1026        >>> g.createZone('zoneUp', 1)
1027        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
1028 annotations=[])
1029        >>> g.addDecisionToZone(0, 'zone')
1030        >>> g.addDecisionToZone(1, 'zone')
1031        >>> g.addDecisionToZone(1, 'zone2')
1032        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
1033        >>> g.namesListing(['A', 'C', 'B'])
1034        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
1035        """
1036        ind = ' ' * indent
1037        if len(decisions) == 0:
1038            return ind + '(none)\n'
1039        else:
1040            result = ''
1041            for dID in decisions:
1042                result += ind + self.identityOf(dID, includeZones) + '\n'
1043            return result
1044
1045    def destinationsListing(
1046        self,
1047        destinations: Dict[base.Transition, base.DecisionID],
1048        includeZones: bool = True,
1049        indent: int = 2
1050    ) -> str:
1051        """
1052        Returns a multi-line string containing an indented listing of
1053        the provided transitions along with their destinations and the
1054        names of those destinations in parentheses. Useful for debugging
1055        & error messages. (Use e.g., `destinationsFrom` to get a
1056        transitions -> destinations dictionary in the required format.)
1057
1058        Uses the string '(no transitions)' when there are no transitions
1059        in the dictionary.
1060
1061        Set `indent` to something other than 2 to control how much
1062        indentation is added.
1063
1064        For example:
1065
1066        >>> g = DecisionGraph()
1067        >>> g.addDecision('A')
1068        0
1069        >>> g.addDecision('B')
1070        1
1071        >>> g.addDecision('C')
1072        2
1073        >>> g.addTransition('A', 'north', 'B', 'south')
1074        >>> g.addTransition('B', 'east', 'C', 'west')
1075        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
1076        >>> g.destinationsListing(g.destinationsFrom('A'))
1077        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
1078        >>> g.destinationsListing(g.destinationsFrom('B'))
1079        '  south to 0 (A)\\n  east to 2 (C)\\n'
1080        >>> g.destinationsListing({})
1081        '  (none)\\n'
1082        >>> g.createZone('zone', 0)
1083        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1084 annotations=[])
1085        >>> g.addDecisionToZone(0, 'zone')
1086        >>> g.destinationsListing(g.destinationsFrom('B'))
1087        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
1088        """
1089        ind = ' ' * indent
1090        if len(destinations) == 0:
1091            return ind + '(none)\n'
1092        else:
1093            result = ''
1094            for transition, dID in destinations.items():
1095                line = f"{transition} to {self.identityOf(dID, includeZones)}"
1096                result += ind + line + '\n'
1097            return result
1098
1099    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
1100        """
1101        Returns the domain that a decision belongs to.
1102        """
1103        dID = self.resolveDecision(decision)
1104        return self.nodes[dID]['domain']
1105
1106    def allDecisionsInDomain(
1107        self,
1108        domain: base.Domain
1109    ) -> Set[base.DecisionID]:
1110        """
1111        Returns the set of all `DecisionID`s for decisions in the
1112        specified domain.
1113        """
1114        return set(dID for dID in self if self.nodes[dID]['domain'] == domain)
1115
1116    def destination(
1117        self,
1118        decision: base.AnyDecisionSpecifier,
1119        transition: base.Transition
1120    ) -> base.DecisionID:
1121        """
1122        Overrides base `UniqueExitsGraph.destination` to raise
1123        `MissingDecisionError` or `MissingTransitionError` as
1124        appropriate, and to work with an `AnyDecisionSpecifier`.
1125        """
1126        dID = self.resolveDecision(decision)
1127        try:
1128            return super().destination(dID, transition)
1129        except KeyError:
1130            raise MissingTransitionError(
1131                f"Transition {transition!r} does not exist at decision"
1132                f" {self.identityOf(dID)}."
1133            )
1134
1135    def getDestination(
1136        self,
1137        decision: base.AnyDecisionSpecifier,
1138        transition: base.Transition,
1139        default: Any = None
1140    ) -> Optional[base.DecisionID]:
1141        """
1142        Overrides base `UniqueExitsGraph.getDestination` with different
1143        argument names, since those matter for the edit DSL.
1144        """
1145        dID = self.resolveDecision(decision)
1146        return super().getDestination(dID, transition)
1147
1148    def destinationsFrom(
1149        self,
1150        decision: base.AnyDecisionSpecifier
1151    ) -> Dict[base.Transition, base.DecisionID]:
1152        """
1153        Override that just changes the type of the exception from a
1154        `KeyError` to a `MissingDecisionError` when the source does not
1155        exist.
1156        """
1157        dID = self.resolveDecision(decision)
1158        return super().destinationsFrom(dID)
1159
1160    def bothEnds(
1161        self,
1162        decision: base.AnyDecisionSpecifier,
1163        transition: base.Transition
1164    ) -> Set[base.DecisionID]:
1165        """
1166        Returns a set containing the `DecisionID`(s) for both the start
1167        and end of the specified transition. Raises a
1168        `MissingDecisionError` or `MissingTransitionError`if the
1169        specified decision and/or transition do not exist.
1170
1171        Note that for actions since the source and destination are the
1172        same, the set will have only one element.
1173        """
1174        dID = self.resolveDecision(decision)
1175        result = {dID}
1176        dest = self.destination(dID, transition)
1177        if dest is not None:
1178            result.add(dest)
1179        return result
1180
1181    def decisionActions(
1182        self,
1183        decision: base.AnyDecisionSpecifier
1184    ) -> Set[base.Transition]:
1185        """
1186        Retrieves the set of self-edges at a decision. Editing the set
1187        will not affect the graph.
1188
1189        Example:
1190
1191        >>> g = DecisionGraph()
1192        >>> g.addDecision('A')
1193        0
1194        >>> g.addDecision('B')
1195        1
1196        >>> g.addDecision('C')
1197        2
1198        >>> g.addAction('A', 'action1')
1199        >>> g.addAction('A', 'action2')
1200        >>> g.addAction('B', 'action3')
1201        >>> sorted(g.decisionActions('A'))
1202        ['action1', 'action2']
1203        >>> g.decisionActions('B')
1204        {'action3'}
1205        >>> g.decisionActions('C')
1206        set()
1207        """
1208        result = set()
1209        dID = self.resolveDecision(decision)
1210        for transition, dest in self.destinationsFrom(dID).items():
1211            if dest == dID:
1212                result.add(transition)
1213        return result
1214
1215    def getTransitionProperties(
1216        self,
1217        decision: base.AnyDecisionSpecifier,
1218        transition: base.Transition
1219    ) -> TransitionProperties:
1220        """
1221        Returns a dictionary containing transition properties for the
1222        specified transition from the specified decision. The properties
1223        included are:
1224
1225        - 'requirement': The requirement for the transition.
1226        - 'consequence': Any consequence of the transition.
1227        - 'tags': Any tags applied to the transition.
1228        - 'annotations': Any annotations on the transition.
1229
1230        The reciprocal of the transition is not included.
1231
1232        The result is a clone of the stored properties; edits to the
1233        dictionary will NOT modify the graph.
1234        """
1235        dID = self.resolveDecision(decision)
1236        dest = self.destination(dID, transition)
1237
1238        info: TransitionProperties = copy.deepcopy(
1239            self.edges[dID, dest, transition]  # type:ignore
1240        )
1241        return {
1242            'requirement': info.get('requirement', base.ReqNothing()),
1243            'consequence': info.get('consequence', []),
1244            'tags': info.get('tags', {}),
1245            'annotations': info.get('annotations', [])
1246        }
1247
1248    def setTransitionProperties(
1249        self,
1250        decision: base.AnyDecisionSpecifier,
1251        transition: base.Transition,
1252        requirement: Optional[base.Requirement] = None,
1253        consequence: Optional[base.Consequence] = None,
1254        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1255        annotations: Optional[List[base.Annotation]] = None
1256    ) -> None:
1257        """
1258        Sets one or more transition properties all at once. Can be used
1259        to set the requirement, consequence, tags, and/or annotations.
1260        Old values are overwritten, although if `None`s are provided (or
1261        arguments are omitted), corresponding properties are not
1262        updated.
1263
1264        To add tags or annotations to existing tags/annotations instead
1265        of replacing them, use `tagTransition` or `annotateTransition`
1266        instead.
1267        """
1268        dID = self.resolveDecision(decision)
1269        if requirement is not None:
1270            self.setTransitionRequirement(dID, transition, requirement)
1271        if consequence is not None:
1272            self.setConsequence(dID, transition, consequence)
1273        if tags is not None:
1274            dest = self.destination(dID, transition)
1275            # TODO: Submit pull request to update MultiDiGraph stubs in
1276            # types-networkx to include OutMultiEdgeView that accepts
1277            # from/to/key tuples as indices.
1278            info = cast(
1279                TransitionProperties,
1280                self.edges[dID, dest, transition]  # type:ignore
1281            )
1282            info['tags'] = tags
1283        if annotations is not None:
1284            dest = self.destination(dID, transition)
1285            info = cast(
1286                TransitionProperties,
1287                self.edges[dID, dest, transition]  # type:ignore
1288            )
1289            info['annotations'] = annotations
1290
1291    def getTransitionRequirement(
1292        self,
1293        decision: base.AnyDecisionSpecifier,
1294        transition: base.Transition
1295    ) -> base.Requirement:
1296        """
1297        Returns the `Requirement` for accessing a specific transition at
1298        a specific decision. For transitions which don't have
1299        requirements, returns a `ReqNothing` instance.
1300        """
1301        dID = self.resolveDecision(decision)
1302        dest = self.destination(dID, transition)
1303
1304        info = cast(
1305            TransitionProperties,
1306            self.edges[dID, dest, transition]  # type:ignore
1307        )
1308
1309        return info.get('requirement', base.ReqNothing())
1310
1311    def setTransitionRequirement(
1312        self,
1313        decision: base.AnyDecisionSpecifier,
1314        transition: base.Transition,
1315        requirement: Optional[base.Requirement]
1316    ) -> None:
1317        """
1318        Sets the `Requirement` for accessing a specific transition at
1319        a specific decision. Raises a `KeyError` if the decision or
1320        transition does not exist.
1321
1322        Deletes the requirement if `None` is given as the requirement.
1323
1324        Use `parsing.ParseFormat.parseRequirement` first if you have a
1325        requirement in string format.
1326
1327        Does not raise an error if deletion is requested for a
1328        non-existent requirement, and silently overwrites any previous
1329        requirement.
1330        """
1331        dID = self.resolveDecision(decision)
1332
1333        dest = self.destination(dID, transition)
1334
1335        info = cast(
1336            TransitionProperties,
1337            self.edges[dID, dest, transition]  # type:ignore
1338        )
1339
1340        if requirement is None:
1341            try:
1342                del info['requirement']
1343            except KeyError:
1344                pass
1345        else:
1346            if not isinstance(requirement, base.Requirement):
1347                raise TypeError(
1348                    f"Invalid requirement type: {type(requirement)}"
1349                )
1350
1351            info['requirement'] = requirement
1352
1353    def getConsequence(
1354        self,
1355        decision: base.AnyDecisionSpecifier,
1356        transition: base.Transition
1357    ) -> base.Consequence:
1358        """
1359        Retrieves the consequence of a transition.
1360
1361        A `KeyError` is raised if the specified decision/transition
1362        combination doesn't exist.
1363        """
1364        dID = self.resolveDecision(decision)
1365
1366        dest = self.destination(dID, transition)
1367
1368        info = cast(
1369            TransitionProperties,
1370            self.edges[dID, dest, transition]  # type:ignore
1371        )
1372
1373        return info.get('consequence', [])
1374
1375    def addConsequence(
1376        self,
1377        decision: base.AnyDecisionSpecifier,
1378        transition: base.Transition,
1379        consequence: base.Consequence
1380    ) -> Tuple[int, int]:
1381        """
1382        Adds the given `Consequence` to the consequence list for the
1383        specified transition, extending that list at the end. Note that
1384        this does NOT make a copy of the consequence, so it should not
1385        be used to copy consequences from one transition to another
1386        without making a deep copy first.
1387
1388        A `MissingDecisionError` or a `MissingTransitionError` is raised
1389        if the specified decision/transition combination doesn't exist.
1390
1391        Returns a pair of integers indicating the minimum and maximum
1392        depth-first-traversal-indices of the added consequence part(s).
1393        The outer consequence list itself (index 0) is not counted.
1394
1395        >>> d = DecisionGraph()
1396        >>> d.addDecision('A')
1397        0
1398        >>> d.addDecision('B')
1399        1
1400        >>> d.addTransition('A', 'fwd', 'B', 'rev')
1401        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
1402        (1, 1)
1403        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
1404        (1, 1)
1405        >>> ef = d.getConsequence('A', 'fwd')
1406        >>> er = d.getConsequence('B', 'rev')
1407        >>> ef == [base.effect(gain='sword')]
1408        True
1409        >>> er == [base.effect(lose='sword')]
1410        True
1411        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
1412        (2, 2)
1413        >>> ef = d.getConsequence('A', 'fwd')
1414        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
1415        True
1416        >>> d.addConsequence(
1417        ...     'A',
1418        ...     'fwd',  # adding to consequence with 3 parts already
1419        ...     [  # outer list not counted because it merges
1420        ...         base.challenge(  # 1 part
1421        ...             None,
1422        ...             0,
1423        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
1424        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
1425        ...         )
1426        ...     ]
1427        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
1428        (3, 7)
1429        """
1430        dID = self.resolveDecision(decision)
1431
1432        dest = self.destination(dID, transition)
1433
1434        info = cast(
1435            TransitionProperties,
1436            self.edges[dID, dest, transition]  # type:ignore
1437        )
1438
1439        existing = info.setdefault('consequence', [])
1440        startIndex = base.countParts(existing)
1441        existing.extend(consequence)
1442        endIndex = base.countParts(existing) - 1
1443        return (startIndex, endIndex)
1444
1445    def setConsequence(
1446        self,
1447        decision: base.AnyDecisionSpecifier,
1448        transition: base.Transition,
1449        consequence: base.Consequence
1450    ) -> None:
1451        """
1452        Replaces the transition consequence for the given transition at
1453        the given decision. Any previous consequence is discarded. See
1454        `Consequence` for the structure of these. Note that this does
1455        NOT make a copy of the consequence, do that first to avoid
1456        effect-entanglement if you're copying a consequence.
1457
1458        A `MissingDecisionError` or a `MissingTransitionError` is raised
1459        if the specified decision/transition combination doesn't exist.
1460        """
1461        dID = self.resolveDecision(decision)
1462
1463        dest = self.destination(dID, transition)
1464
1465        info = cast(
1466            TransitionProperties,
1467            self.edges[dID, dest, transition]  # type:ignore
1468        )
1469
1470        info['consequence'] = consequence
1471
1472    def addEquivalence(
1473        self,
1474        requirement: base.Requirement,
1475        capabilityOrMechanismState: Union[
1476            base.Capability,
1477            Tuple[base.MechanismID, base.MechanismState]
1478        ]
1479    ) -> None:
1480        """
1481        Adds the given requirement as an equivalence for the given
1482        capability or the given mechanism state. Note that having a
1483        capability via an equivalence does not count as actually having
1484        that capability; it only counts for the purpose of satisfying
1485        `Requirement`s.
1486
1487        Note also that because a mechanism-based requirement looks up
1488        the specific mechanism locally based on a name, an equivalence
1489        defined in one location may affect mechanism requirements in
1490        other locations unless the mechanism name in the requirement is
1491        zone-qualified to be specific. But in such situations the base
1492        mechanism would have caused issues in any case.
1493        """
1494        self.equivalences.setdefault(
1495            capabilityOrMechanismState,
1496            set()
1497        ).add(requirement)
1498
1499    def removeEquivalence(
1500        self,
1501        requirement: base.Requirement,
1502        capabilityOrMechanismState: Union[
1503            base.Capability,
1504            Tuple[base.MechanismID, base.MechanismState]
1505        ]
1506    ) -> None:
1507        """
1508        Removes an equivalence. Raises a `KeyError` if no such
1509        equivalence existed.
1510        """
1511        self.equivalences[capabilityOrMechanismState].remove(requirement)
1512
1513    def hasAnyEquivalents(
1514        self,
1515        capabilityOrMechanismState: Union[
1516            base.Capability,
1517            Tuple[base.MechanismID, base.MechanismState]
1518        ]
1519    ) -> bool:
1520        """
1521        Returns `True` if the given capability or mechanism state has at
1522        least one equivalence.
1523        """
1524        return capabilityOrMechanismState in self.equivalences
1525
1526    def allEquivalents(
1527        self,
1528        capabilityOrMechanismState: Union[
1529            base.Capability,
1530            Tuple[base.MechanismID, base.MechanismState]
1531        ]
1532    ) -> Set[base.Requirement]:
1533        """
1534        Returns the set of equivalences for the given capability. This is
1535        a live set which may be modified (it's probably better to use
1536        `addEquivalence` and `removeEquivalence` instead...).
1537        """
1538        return self.equivalences.setdefault(
1539            capabilityOrMechanismState,
1540            set()
1541        )
1542
1543    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
1544        """
1545        Specifies a new reversion type, so that when used in a reversion
1546        aspects set with a colon before the name, all items in the
1547        `equivalentTo` value will be added to that set. These may
1548        include other custom reversion type names (with the colon) but
1549        take care not to create an equivalence loop which would result
1550        in a crash.
1551
1552        If you re-use the same name, it will override the old equivalence
1553        for that name.
1554        """
1555        self.reversionTypes[name] = equivalentTo
1556
1557    def addAction(
1558        self,
1559        decision: base.AnyDecisionSpecifier,
1560        action: base.Transition,
1561        requires: Optional[base.Requirement] = None,
1562        consequence: Optional[base.Consequence] = None,
1563        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1564        annotations: Optional[List[base.Annotation]] = None,
1565    ) -> None:
1566        """
1567        Adds the given action as a possibility at the given decision. An
1568        action is just a self-edge, which can have requirements like any
1569        edge, and which can have consequences like any edge.
1570        The optional arguments are given to `setTransitionRequirement`
1571        and `setConsequence`; see those functions for descriptions
1572        of what they mean.
1573
1574        Raises a `KeyError` if a transition with the given name already
1575        exists at the given decision.
1576        """
1577        if tags is None:
1578            tags = {}
1579        if annotations is None:
1580            annotations = []
1581
1582        dID = self.resolveDecision(decision)
1583
1584        self.add_edge(
1585            dID,
1586            dID,
1587            key=action,
1588            tags=tags,
1589            annotations=annotations
1590        )
1591        self.setTransitionRequirement(dID, action, requires)
1592        if consequence is not None:
1593            self.setConsequence(dID, action, consequence)
1594
1595    def tagDecision(
1596        self,
1597        decision: base.AnyDecisionSpecifier,
1598        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1599        tagValue: Union[
1600            base.TagValue,
1601            type[base.NoTagValue]
1602        ] = base.NoTagValue
1603    ) -> None:
1604        """
1605        Adds a tag (or many tags from a dictionary of tags) to a
1606        decision, using `1` as the value if no value is provided. It's
1607        a `ValueError` to provide a value when a dictionary of tags is
1608        provided to set multiple tags at once.
1609
1610        Note that certain tags have special meanings:
1611
1612        - 'unconfirmed' is used for decisions that represent unconfirmed
1613            parts of the graph (this is separate from the 'unknown'
1614            and/or 'hypothesized' exploration statuses, which are only
1615            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
1616            Various methods require this tag and many also add or remove
1617            it.
1618        """
1619        if isinstance(tagOrTags, base.Tag):
1620            if tagValue is base.NoTagValue:
1621                tagValue = 1
1622
1623            # Not sure why this cast is necessary given the `if` above...
1624            tagValue = cast(base.TagValue, tagValue)
1625
1626            tagOrTags = {tagOrTags: tagValue}
1627
1628        elif tagValue is not base.NoTagValue:
1629            raise ValueError(
1630                "Provided a dictionary to update multiple tags, but"
1631                " also a tag value."
1632            )
1633
1634        dID = self.resolveDecision(decision)
1635
1636        tagsAlready = self.nodes[dID].setdefault('tags', {})
1637        tagsAlready.update(tagOrTags)
1638
1639    def untagDecision(
1640        self,
1641        decision: base.AnyDecisionSpecifier,
1642        tag: base.Tag
1643    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1644        """
1645        Removes a tag from a decision. Returns the tag's old value if
1646        the tag was present and got removed, or `NoTagValue` if the tag
1647        wasn't present.
1648        """
1649        dID = self.resolveDecision(decision)
1650
1651        target = self.nodes[dID]['tags']
1652        try:
1653            return target.pop(tag)
1654        except KeyError:
1655            return base.NoTagValue
1656
1657    def decisionTags(
1658        self,
1659        decision: base.AnyDecisionSpecifier
1660    ) -> Dict[base.Tag, base.TagValue]:
1661        """
1662        Returns the dictionary of tags for a decision. Edits to the
1663        returned value will be applied to the graph.
1664        """
1665        dID = self.resolveDecision(decision)
1666
1667        return self.nodes[dID]['tags']
1668
1669    def annotateDecision(
1670        self,
1671        decision: base.AnyDecisionSpecifier,
1672        annotationOrAnnotations: Union[
1673            base.Annotation,
1674            Sequence[base.Annotation]
1675        ]
1676    ) -> None:
1677        """
1678        Adds an annotation to a decision's annotations list.
1679        """
1680        dID = self.resolveDecision(decision)
1681
1682        if isinstance(annotationOrAnnotations, base.Annotation):
1683            annotationOrAnnotations = [annotationOrAnnotations]
1684        self.nodes[dID]['annotations'].extend(annotationOrAnnotations)
1685
1686    def decisionAnnotations(
1687        self,
1688        decision: base.AnyDecisionSpecifier
1689    ) -> List[base.Annotation]:
1690        """
1691        Returns the list of annotations for the specified decision.
1692        Modifying the list affects the graph.
1693        """
1694        dID = self.resolveDecision(decision)
1695
1696        return self.nodes[dID]['annotations']
1697
1698    def tagTransition(
1699        self,
1700        decision: base.AnyDecisionSpecifier,
1701        transition: base.Transition,
1702        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1703        tagValue: Union[
1704            base.TagValue,
1705            type[base.NoTagValue]
1706        ] = base.NoTagValue
1707    ) -> None:
1708        """
1709        Adds a tag (or each tag from a dictionary) to a transition
1710        coming out of a specific decision. `1` will be used as the
1711        default value if a single tag is supplied; supplying a tag value
1712        when providing a dictionary of multiple tags to update is a
1713        `ValueError`.
1714
1715        Note that certain transition tags have special meanings:
1716        - 'trigger' causes any actions (but not normal transitions) that
1717            it applies to to be automatically triggered when
1718            `advanceSituation` is called and the decision they're
1719            attached to is active in the new situation (as long as the
1720            action's requirements are met). This happens once per
1721            situation; use 'wait' steps to re-apply triggers.
1722        """
1723        dID = self.resolveDecision(decision)
1724
1725        dest = self.destination(dID, transition)
1726        if isinstance(tagOrTags, base.Tag):
1727            if tagValue is base.NoTagValue:
1728                tagValue = 1
1729
1730            # Not sure why this is necessary given the `if` above...
1731            tagValue = cast(base.TagValue, tagValue)
1732
1733            tagOrTags = {tagOrTags: tagValue}
1734        elif tagValue is not base.NoTagValue:
1735            raise ValueError(
1736                "Provided a dictionary to update multiple tags, but"
1737                " also a tag value."
1738            )
1739
1740        info = cast(
1741            TransitionProperties,
1742            self.edges[dID, dest, transition]  # type:ignore
1743        )
1744
1745        info.setdefault('tags', {}).update(tagOrTags)
1746
1747    def untagTransition(
1748        self,
1749        decision: base.AnyDecisionSpecifier,
1750        transition: base.Transition,
1751        tagOrTags: Union[base.Tag, Set[base.Tag]]
1752    ) -> None:
1753        """
1754        Removes a tag (or each tag in a set) from a transition coming out
1755        of a specific decision. Raises a `KeyError` if (one of) the
1756        specified tag(s) is not currently applied to the specified
1757        transition.
1758        """
1759        dID = self.resolveDecision(decision)
1760
1761        dest = self.destination(dID, transition)
1762        if isinstance(tagOrTags, base.Tag):
1763            tagOrTags = {tagOrTags}
1764
1765        info = cast(
1766            TransitionProperties,
1767            self.edges[dID, dest, transition]  # type:ignore
1768        )
1769        tagsAlready = info.setdefault('tags', {})
1770
1771        for tag in tagOrTags:
1772            tagsAlready.pop(tag)
1773
1774    def transitionTags(
1775        self,
1776        decision: base.AnyDecisionSpecifier,
1777        transition: base.Transition
1778    ) -> Dict[base.Tag, base.TagValue]:
1779        """
1780        Returns the dictionary of tags for a transition. Edits to the
1781        returned dictionary will be applied to the graph.
1782        """
1783        dID = self.resolveDecision(decision)
1784
1785        dest = self.destination(dID, transition)
1786        info = cast(
1787            TransitionProperties,
1788            self.edges[dID, dest, transition]  # type:ignore
1789        )
1790        return info.setdefault('tags', {})
1791
1792    def annotateTransition(
1793        self,
1794        decision: base.AnyDecisionSpecifier,
1795        transition: base.Transition,
1796        annotations: Union[base.Annotation, Sequence[base.Annotation]]
1797    ) -> None:
1798        """
1799        Adds an annotation (or a sequence of annotations) to a
1800        transition's annotations list.
1801        """
1802        dID = self.resolveDecision(decision)
1803
1804        dest = self.destination(dID, transition)
1805        if isinstance(annotations, base.Annotation):
1806            annotations = [annotations]
1807        info = cast(
1808            TransitionProperties,
1809            self.edges[dID, dest, transition]  # type:ignore
1810        )
1811        info['annotations'].extend(annotations)
1812
1813    def transitionAnnotations(
1814        self,
1815        decision: base.AnyDecisionSpecifier,
1816        transition: base.Transition
1817    ) -> List[base.Annotation]:
1818        """
1819        Returns the annotation list for a specific transition at a
1820        specific decision. Editing the list affects the graph.
1821        """
1822        dID = self.resolveDecision(decision)
1823
1824        dest = self.destination(dID, transition)
1825        info = cast(
1826            TransitionProperties,
1827            self.edges[dID, dest, transition]  # type:ignore
1828        )
1829        return info['annotations']
1830
1831    def annotateZone(
1832        self,
1833        zone: base.Zone,
1834        annotations: Union[base.Annotation, Sequence[base.Annotation]]
1835    ) -> None:
1836        """
1837        Adds an annotation (or many annotations from a sequence) to a
1838        zone.
1839
1840        Raises a `MissingZoneError` if the specified zone does not exist.
1841        """
1842        if zone not in self.zones:
1843            raise MissingZoneError(
1844                f"Can't add annotation(s) to zone {zone!r} because that"
1845                f" zone doesn't exist yet."
1846            )
1847
1848        if isinstance(annotations, base.Annotation):
1849            annotations = [ annotations ]
1850
1851        self.zones[zone].annotations.extend(annotations)
1852
1853    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
1854        """
1855        Returns the list of annotations for the specified zone (empty if
1856        none have been added yet).
1857        """
1858        return self.zones[zone].annotations
1859
1860    def tagZone(
1861        self,
1862        zone: base.Zone,
1863        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1864        tagValue: Union[
1865            base.TagValue,
1866            type[base.NoTagValue]
1867        ] = base.NoTagValue
1868    ) -> None:
1869        """
1870        Adds a tag (or many tags from a dictionary of tags) to a
1871        zone, using `1` as the value if no value is provided. It's
1872        a `ValueError` to provide a value when a dictionary of tags is
1873        provided to set multiple tags at once.
1874
1875        Raises a `MissingZoneError` if the specified zone does not exist.
1876        """
1877        if zone not in self.zones:
1878            raise MissingZoneError(
1879                f"Can't add tag(s) to zone {zone!r} because that zone"
1880                f" doesn't exist yet."
1881            )
1882
1883        if isinstance(tagOrTags, base.Tag):
1884            if tagValue is base.NoTagValue:
1885                tagValue = 1
1886
1887            # Not sure why this cast is necessary given the `if` above...
1888            tagValue = cast(base.TagValue, tagValue)
1889
1890            tagOrTags = {tagOrTags: tagValue}
1891
1892        elif tagValue is not base.NoTagValue:
1893            raise ValueError(
1894                "Provided a dictionary to update multiple tags, but"
1895                " also a tag value."
1896            )
1897
1898        tagsAlready = self.zones[zone].tags
1899        tagsAlready.update(tagOrTags)
1900
1901    def untagZone(
1902        self,
1903        zone: base.Zone,
1904        tag: base.Tag
1905    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1906        """
1907        Removes a tag from a zone. Returns the tag's old value if the
1908        tag was present and got removed, or `NoTagValue` if the tag
1909        wasn't present.
1910
1911        Raises a `MissingZoneError` if the specified zone does not exist.
1912        """
1913        if zone not in self.zones:
1914            raise MissingZoneError(
1915                f"Can't remove tag {tag!r} from zone {zone!r} because"
1916                f" that zone doesn't exist yet."
1917            )
1918        target = self.zones[zone].tags
1919        try:
1920            return target.pop(tag)
1921        except KeyError:
1922            return base.NoTagValue
1923
1924    def zoneTags(
1925        self,
1926        zone: base.Zone
1927    ) -> Dict[base.Tag, base.TagValue]:
1928        """
1929        Returns the dictionary of tags for a zone. Edits to the returned
1930        value will be applied to the graph. Returns an empty tags
1931        dictionary if called on a zone that didn't have any tags
1932        previously, but raises a `MissingZoneError` if attempting to get
1933        tags for a zone which does not exist.
1934
1935        For example:
1936
1937        >>> g = DecisionGraph()
1938        >>> g.addDecision('A')
1939        0
1940        >>> g.addDecision('B')
1941        1
1942        >>> g.createZone('Zone')
1943        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1944 annotations=[])
1945        >>> g.tagZone('Zone', 'color', 'blue')
1946        >>> g.tagZone(
1947        ...     'Zone',
1948        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
1949        ... )
1950        >>> g.untagZone('Zone', 'sound')
1951        'loud'
1952        >>> g.zoneTags('Zone')
1953        {'color': 'red', 'shape': 'square'}
1954        """
1955        if zone in self.zones:
1956            return self.zones[zone].tags
1957        else:
1958            raise MissingZoneError(
1959                f"Tags for zone {zone!r} don't exist because that"
1960                f" zone has not been created yet."
1961            )
1962
1963    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
1964        """
1965        Creates an empty zone with the given name at the given level
1966        (default 0). Raises a `ZoneCollisionError` if that zone name is
1967        already in use (at any level), including if it's in use by a
1968        decision.
1969
1970        Raises an `InvalidLevelError` if the level value is less than 0.
1971
1972        Returns the `ZoneInfo` for the new blank zone.
1973
1974        For example:
1975
1976        >>> d = DecisionGraph()
1977        >>> d.createZone('Z', 0)
1978        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1979 annotations=[])
1980        >>> d.getZoneInfo('Z')
1981        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1982 annotations=[])
1983        >>> d.createZone('Z2', 0)
1984        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1985 annotations=[])
1986        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
1987        Traceback (most recent call last):
1988        ...
1989        exploration.core.InvalidLevelError...
1990        >>> d.createZone('Z2')  # Name Z2 is already in use
1991        Traceback (most recent call last):
1992        ...
1993        exploration.core.ZoneCollisionError...
1994        """
1995        if level < 0:
1996            raise InvalidLevelError(
1997                "Cannot create a zone with a negative level."
1998            )
1999        if zone in self.zones:
2000            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
2001        if zone in self:
2002            raise ZoneCollisionError(
2003                f"A decision named {zone!r} already exists, so a zone"
2004                f" with that name cannot be created."
2005            )
2006        info: base.ZoneInfo = base.ZoneInfo(
2007            level=level,
2008            parents=set(),
2009            contents=set(),
2010            tags={},
2011            annotations=[]
2012        )
2013        self.zones[zone] = info
2014        return info
2015
2016    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
2017        """
2018        Returns the `ZoneInfo` (level, parents, and contents) for the
2019        specified zone, or `None` if that zone does not exist.
2020
2021        For example:
2022
2023        >>> d = DecisionGraph()
2024        >>> d.createZone('Z', 0)
2025        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2026 annotations=[])
2027        >>> d.getZoneInfo('Z')
2028        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2029 annotations=[])
2030        >>> d.createZone('Z2', 0)
2031        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2032 annotations=[])
2033        >>> d.getZoneInfo('Z2')
2034        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2035 annotations=[])
2036        """
2037        return self.zones.get(zone)
2038
2039    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
2040        """
2041        Deletes the specified zone, returning a `ZoneInfo` object with
2042        the information on the level, parents, and contents of that zone.
2043
2044        Raises a `MissingZoneError` if the zone in question does not
2045        exist.
2046
2047        For example:
2048
2049        >>> d = DecisionGraph()
2050        >>> d.createZone('Z', 0)
2051        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2052 annotations=[])
2053        >>> d.getZoneInfo('Z')
2054        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2055 annotations=[])
2056        >>> d.deleteZone('Z')
2057        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2058 annotations=[])
2059        >>> d.getZoneInfo('Z') is None  # no info any more
2060        True
2061        >>> d.deleteZone('Z')  # can't re-delete
2062        Traceback (most recent call last):
2063        ...
2064        exploration.core.MissingZoneError...
2065        """
2066        info = self.getZoneInfo(zone)
2067        if info is None:
2068            raise MissingZoneError(
2069                f"Cannot delete zone {zone!r}: it does not exist."
2070            )
2071        for sub in info.contents:
2072            if 'zones' in self.nodes[sub]:
2073                try:
2074                    self.nodes[sub]['zones'].remove(zone)
2075                except KeyError:
2076                    pass
2077        del self.zones[zone]
2078        return info
2079
2080    def addDecisionToZone(
2081        self,
2082        decision: base.AnyDecisionSpecifier,
2083        zone: base.Zone
2084    ) -> None:
2085        """
2086        Adds a decision directly to a zone. Should normally only be used
2087        with level-0 zones. Raises a `MissingZoneError` if the specified
2088        zone did not already exist.
2089
2090        For example:
2091
2092        >>> d = DecisionGraph()
2093        >>> d.addDecision('A')
2094        0
2095        >>> d.addDecision('B')
2096        1
2097        >>> d.addDecision('C')
2098        2
2099        >>> d.createZone('Z', 0)
2100        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2101 annotations=[])
2102        >>> d.addDecisionToZone('A', 'Z')
2103        >>> d.getZoneInfo('Z')
2104        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
2105 annotations=[])
2106        >>> d.addDecisionToZone('B', 'Z')
2107        >>> d.getZoneInfo('Z')
2108        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2109 annotations=[])
2110        """
2111        dID = self.resolveDecision(decision)
2112
2113        if zone not in self.zones:
2114            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2115
2116        self.zones[zone].contents.add(dID)
2117        self.nodes[dID].setdefault('zones', set()).add(zone)
2118
2119    def removeDecisionFromZone(
2120        self,
2121        decision: base.AnyDecisionSpecifier,
2122        zone: base.Zone
2123    ) -> bool:
2124        """
2125        Removes a decision from a zone if it had been in it, returning
2126        True if that decision had been in that zone, and False if it was
2127        not in that zone, including if that zone didn't exist.
2128
2129        Note that this only removes a decision from direct zone
2130        membership. If the decision is a member of one or more zones
2131        which are (directly or indirectly) sub-zones of the target zone,
2132        the decision will remain in those zones, and will still be
2133        indirectly part of the target zone afterwards.
2134
2135        Examples:
2136
2137        >>> g = DecisionGraph()
2138        >>> g.addDecision('A')
2139        0
2140        >>> g.addDecision('B')
2141        1
2142        >>> g.createZone('level0', 0)
2143        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2144 annotations=[])
2145        >>> g.createZone('level1', 1)
2146        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2147 annotations=[])
2148        >>> g.createZone('level2', 2)
2149        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2150 annotations=[])
2151        >>> g.createZone('level3', 3)
2152        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2153 annotations=[])
2154        >>> g.addDecisionToZone('A', 'level0')
2155        >>> g.addDecisionToZone('B', 'level0')
2156        >>> g.addZoneToZone('level0', 'level1')
2157        >>> g.addZoneToZone('level1', 'level2')
2158        >>> g.addZoneToZone('level2', 'level3')
2159        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2160        >>> g.removeDecisionFromZone('A', 'level1')
2161        False
2162        >>> g.zoneParents(0)
2163        {'level0'}
2164        >>> g.removeDecisionFromZone('A', 'level0')
2165        True
2166        >>> g.zoneParents(0)
2167        set()
2168        >>> g.removeDecisionFromZone('A', 'level0')
2169        False
2170        >>> g.removeDecisionFromZone('B', 'level0')
2171        True
2172        >>> g.zoneParents(1)
2173        {'level2'}
2174        >>> g.removeDecisionFromZone('B', 'level0')
2175        False
2176        >>> g.removeDecisionFromZone('B', 'level2')
2177        True
2178        >>> g.zoneParents(1)
2179        set()
2180        """
2181        dID = self.resolveDecision(decision)
2182
2183        if zone not in self.zones:
2184            return False
2185
2186        info = self.zones[zone]
2187        if dID not in info.contents:
2188            return False
2189        else:
2190            info.contents.remove(dID)
2191            try:
2192                self.nodes[dID]['zones'].remove(zone)
2193            except KeyError:
2194                pass
2195            return True
2196
2197    def addZoneToZone(
2198        self,
2199        addIt: base.Zone,
2200        addTo: base.Zone
2201    ) -> None:
2202        """
2203        Adds a zone to another zone. The `addIt` one must be at a
2204        strictly lower level than the `addTo` zone, or an
2205        `InvalidLevelError` will be raised.
2206
2207        If the zone to be added didn't already exist, it is created at
2208        one level below the target zone. Similarly, if the zone being
2209        added to didn't already exist, it is created at one level above
2210        the target zone. If neither existed, a `MissingZoneError` will
2211        be raised.
2212
2213        For example:
2214
2215        >>> d = DecisionGraph()
2216        >>> d.addDecision('A')
2217        0
2218        >>> d.addDecision('B')
2219        1
2220        >>> d.addDecision('C')
2221        2
2222        >>> d.createZone('Z', 0)
2223        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2224 annotations=[])
2225        >>> d.addDecisionToZone('A', 'Z')
2226        >>> d.addDecisionToZone('B', 'Z')
2227        >>> d.getZoneInfo('Z')
2228        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2229 annotations=[])
2230        >>> d.createZone('Z2', 0)
2231        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2232 annotations=[])
2233        >>> d.addDecisionToZone('B', 'Z2')
2234        >>> d.addDecisionToZone('C', 'Z2')
2235        >>> d.getZoneInfo('Z2')
2236        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2237 annotations=[])
2238        >>> d.createZone('l1Z', 1)
2239        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2240 annotations=[])
2241        >>> d.createZone('l2Z', 2)
2242        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2243 annotations=[])
2244        >>> d.addZoneToZone('Z', 'l1Z')
2245        >>> d.getZoneInfo('Z')
2246        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
2247 annotations=[])
2248        >>> d.getZoneInfo('l1Z')
2249        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2250 annotations=[])
2251        >>> d.addZoneToZone('l1Z', 'l2Z')
2252        >>> d.getZoneInfo('l1Z')
2253        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2254 annotations=[])
2255        >>> d.getZoneInfo('l2Z')
2256        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2257 annotations=[])
2258        >>> d.addZoneToZone('Z2', 'l2Z')
2259        >>> d.getZoneInfo('Z2')
2260        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
2261 annotations=[])
2262        >>> l2i = d.getZoneInfo('l2Z')
2263        >>> l2i.level
2264        2
2265        >>> l2i.parents
2266        set()
2267        >>> sorted(l2i.contents)
2268        ['Z2', 'l1Z']
2269        >>> d.addZoneToZone('NZ', 'NZ2')
2270        Traceback (most recent call last):
2271        ...
2272        exploration.core.MissingZoneError...
2273        >>> d.addZoneToZone('Z', 'l1Z2')
2274        >>> zi = d.getZoneInfo('Z')
2275        >>> zi.level
2276        0
2277        >>> sorted(zi.parents)
2278        ['l1Z', 'l1Z2']
2279        >>> sorted(zi.contents)
2280        [0, 1]
2281        >>> d.getZoneInfo('l1Z2')
2282        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2283 annotations=[])
2284        >>> d.addZoneToZone('NZ', 'l1Z')
2285        >>> d.getZoneInfo('NZ')
2286        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2287 annotations=[])
2288        >>> zi = d.getZoneInfo('l1Z')
2289        >>> zi.level
2290        1
2291        >>> zi.parents
2292        {'l2Z'}
2293        >>> sorted(zi.contents)
2294        ['NZ', 'Z']
2295        """
2296        # Create one or the other (but not both) if they're missing
2297        addInfo = self.getZoneInfo(addIt)
2298        toInfo = self.getZoneInfo(addTo)
2299        if addInfo is None and toInfo is None:
2300            raise MissingZoneError(
2301                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
2302                f" exists already."
2303            )
2304
2305        # Create missing addIt
2306        elif addInfo is None:
2307            toInfo = cast(base.ZoneInfo, toInfo)
2308            newLevel = toInfo.level - 1
2309            if newLevel < 0:
2310                raise InvalidLevelError(
2311                    f"Zone {addTo!r} is at level {toInfo.level} and so"
2312                    f" a new zone cannot be added underneath it."
2313                )
2314            addInfo = self.createZone(addIt, newLevel)
2315
2316        # Create missing addTo
2317        elif toInfo is None:
2318            addInfo = cast(base.ZoneInfo, addInfo)
2319            newLevel = addInfo.level + 1
2320            if newLevel < 0:
2321                raise InvalidLevelError(
2322                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
2323                    f" and so a new zone cannot be added above it."
2324                )
2325            toInfo = self.createZone(addTo, newLevel)
2326
2327        # Now both addInfo and toInfo are defined
2328        if addInfo.level >= toInfo.level:
2329            raise InvalidLevelError(
2330                f"Cannot add zone {addIt!r} at level {addInfo.level}"
2331                f" to zone {addTo!r} at level {toInfo.level}: zones can"
2332                f" only contain zones of lower levels."
2333            )
2334
2335        # Now both addInfo and toInfo are defined
2336        toInfo.contents.add(addIt)
2337        addInfo.parents.add(addTo)
2338
2339    def removeZoneFromZone(
2340        self,
2341        removeIt: base.Zone,
2342        removeFrom: base.Zone
2343    ) -> bool:
2344        """
2345        Removes a zone from a zone if it had been in it, returning True
2346        if that zone had been in that zone, and False if it was not in
2347        that zone, including if either zone did not exist.
2348
2349        For example:
2350
2351        >>> d = DecisionGraph()
2352        >>> d.createZone('Z', 0)
2353        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2354 annotations=[])
2355        >>> d.createZone('Z2', 0)
2356        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2357 annotations=[])
2358        >>> d.createZone('l1Z', 1)
2359        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2360 annotations=[])
2361        >>> d.createZone('l2Z', 2)
2362        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2363 annotations=[])
2364        >>> d.addZoneToZone('Z', 'l1Z')
2365        >>> d.addZoneToZone('l1Z', 'l2Z')
2366        >>> d.getZoneInfo('Z')
2367        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2368 annotations=[])
2369        >>> d.getZoneInfo('l1Z')
2370        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2371 annotations=[])
2372        >>> d.getZoneInfo('l2Z')
2373        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2374 annotations=[])
2375        >>> d.removeZoneFromZone('l1Z', 'l2Z')
2376        True
2377        >>> d.getZoneInfo('l1Z')
2378        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2379 annotations=[])
2380        >>> d.getZoneInfo('l2Z')
2381        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2382 annotations=[])
2383        >>> d.removeZoneFromZone('Z', 'l1Z')
2384        True
2385        >>> d.getZoneInfo('Z')
2386        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2387 annotations=[])
2388        >>> d.getZoneInfo('l1Z')
2389        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2390 annotations=[])
2391        >>> d.removeZoneFromZone('Z', 'l1Z')
2392        False
2393        >>> d.removeZoneFromZone('Z', 'madeup')
2394        False
2395        >>> d.removeZoneFromZone('nope', 'madeup')
2396        False
2397        >>> d.removeZoneFromZone('nope', 'l1Z')
2398        False
2399        """
2400        remInfo = self.getZoneInfo(removeIt)
2401        fromInfo = self.getZoneInfo(removeFrom)
2402
2403        if remInfo is None or fromInfo is None:
2404            return False
2405
2406        if removeIt not in fromInfo.contents:
2407            return False
2408
2409        remInfo.parents.remove(removeFrom)
2410        fromInfo.contents.remove(removeIt)
2411        return True
2412
2413    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2414        """
2415        Returns a set of all decisions included directly in the given
2416        zone, not counting decisions included via intermediate
2417        sub-zones (see `allDecisionsInZone` to include those).
2418
2419        Raises a `MissingZoneError` if the specified zone does not
2420        exist.
2421
2422        The returned set is a copy, not a live editable set.
2423
2424        For example:
2425
2426        >>> d = DecisionGraph()
2427        >>> d.addDecision('A')
2428        0
2429        >>> d.addDecision('B')
2430        1
2431        >>> d.addDecision('C')
2432        2
2433        >>> d.createZone('Z', 0)
2434        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2435 annotations=[])
2436        >>> d.addDecisionToZone('A', 'Z')
2437        >>> d.addDecisionToZone('B', 'Z')
2438        >>> d.getZoneInfo('Z')
2439        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2440 annotations=[])
2441        >>> d.decisionsInZone('Z')
2442        {0, 1}
2443        >>> d.createZone('Z2', 0)
2444        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2445 annotations=[])
2446        >>> d.addDecisionToZone('B', 'Z2')
2447        >>> d.addDecisionToZone('C', 'Z2')
2448        >>> d.getZoneInfo('Z2')
2449        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2450 annotations=[])
2451        >>> d.decisionsInZone('Z')
2452        {0, 1}
2453        >>> d.decisionsInZone('Z2')
2454        {1, 2}
2455        >>> d.createZone('l1Z', 1)
2456        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2457 annotations=[])
2458        >>> d.addZoneToZone('Z', 'l1Z')
2459        >>> d.decisionsInZone('Z')
2460        {0, 1}
2461        >>> d.decisionsInZone('l1Z')
2462        set()
2463        >>> d.decisionsInZone('madeup')
2464        Traceback (most recent call last):
2465        ...
2466        exploration.core.MissingZoneError...
2467        >>> zDec = d.decisionsInZone('Z')
2468        >>> zDec.add(2)  # won't affect the zone
2469        >>> zDec
2470        {0, 1, 2}
2471        >>> d.decisionsInZone('Z')
2472        {0, 1}
2473        """
2474        info = self.getZoneInfo(zone)
2475        if info is None:
2476            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2477
2478        # Everything that's not a zone must be a decision
2479        return {
2480            item
2481            for item in info.contents
2482            if isinstance(item, base.DecisionID)
2483        }
2484
2485    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
2486        """
2487        Returns the set of all immediate sub-zones of the given zone.
2488        Will be an empty set if there are no sub-zones; raises a
2489        `MissingZoneError` if the specified zone does not exit.
2490
2491        The returned set is a copy, not a live editable set.
2492
2493        For example:
2494
2495        >>> d = DecisionGraph()
2496        >>> d.createZone('Z', 0)
2497        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2498 annotations=[])
2499        >>> d.subZones('Z')
2500        set()
2501        >>> d.createZone('l1Z', 1)
2502        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2503 annotations=[])
2504        >>> d.addZoneToZone('Z', 'l1Z')
2505        >>> d.subZones('Z')
2506        set()
2507        >>> d.subZones('l1Z')
2508        {'Z'}
2509        >>> s = d.subZones('l1Z')
2510        >>> s.add('Q')  # doesn't affect the zone
2511        >>> sorted(s)
2512        ['Q', 'Z']
2513        >>> d.subZones('l1Z')
2514        {'Z'}
2515        >>> d.subZones('madeup')
2516        Traceback (most recent call last):
2517        ...
2518        exploration.core.MissingZoneError...
2519        """
2520        info = self.getZoneInfo(zone)
2521        if info is None:
2522            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2523
2524        # Sub-zones will appear in self.zones
2525        return {
2526            item
2527            for item in info.contents
2528            if isinstance(item, base.Zone)
2529        }
2530
2531    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2532        """
2533        Returns a set containing all decisions in the given zone,
2534        including those included via sub-zones.
2535
2536        Raises a `MissingZoneError` if the specified zone does not
2537        exist.`
2538
2539        For example:
2540
2541        >>> d = DecisionGraph()
2542        >>> d.addDecision('A')
2543        0
2544        >>> d.addDecision('B')
2545        1
2546        >>> d.addDecision('C')
2547        2
2548        >>> d.createZone('Z', 0)
2549        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2550 annotations=[])
2551        >>> d.addDecisionToZone('A', 'Z')
2552        >>> d.addDecisionToZone('B', 'Z')
2553        >>> d.getZoneInfo('Z')
2554        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2555 annotations=[])
2556        >>> d.decisionsInZone('Z')
2557        {0, 1}
2558        >>> d.allDecisionsInZone('Z')
2559        {0, 1}
2560        >>> d.createZone('Z2', 0)
2561        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2562 annotations=[])
2563        >>> d.addDecisionToZone('B', 'Z2')
2564        >>> d.addDecisionToZone('C', 'Z2')
2565        >>> d.getZoneInfo('Z2')
2566        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2567 annotations=[])
2568        >>> d.decisionsInZone('Z')
2569        {0, 1}
2570        >>> d.decisionsInZone('Z2')
2571        {1, 2}
2572        >>> d.allDecisionsInZone('Z2')
2573        {1, 2}
2574        >>> d.createZone('l1Z', 1)
2575        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2576 annotations=[])
2577        >>> d.createZone('l2Z', 2)
2578        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2579 annotations=[])
2580        >>> d.addZoneToZone('Z', 'l1Z')
2581        >>> d.addZoneToZone('l1Z', 'l2Z')
2582        >>> d.addZoneToZone('Z2', 'l2Z')
2583        >>> d.decisionsInZone('Z')
2584        {0, 1}
2585        >>> d.decisionsInZone('Z2')
2586        {1, 2}
2587        >>> d.decisionsInZone('l1Z')
2588        set()
2589        >>> d.allDecisionsInZone('l1Z')
2590        {0, 1}
2591        >>> d.allDecisionsInZone('l2Z')
2592        {0, 1, 2}
2593        """
2594        result: Set[base.DecisionID] = set()
2595        info = self.getZoneInfo(zone)
2596        if info is None:
2597            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2598
2599        for item in info.contents:
2600            if isinstance(item, base.Zone):
2601                # This can't be an error because of the condition above
2602                result |= self.allDecisionsInZone(item)
2603            else:  # it's a decision
2604                result.add(item)
2605
2606        return result
2607
2608    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
2609        """
2610        Returns the hierarchy level of the given zone, as stored in its
2611        zone info.
2612
2613        Raises a `MissingZoneError` if the specified zone does not
2614        exist.
2615
2616        For example:
2617
2618        >>> d = DecisionGraph()
2619        >>> d.createZone('Z', 0)
2620        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2621 annotations=[])
2622        >>> d.createZone('l1Z', 1)
2623        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2624 annotations=[])
2625        >>> d.createZone('l5Z', 5)
2626        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
2627 annotations=[])
2628        >>> d.zoneHierarchyLevel('Z')
2629        0
2630        >>> d.zoneHierarchyLevel('l1Z')
2631        1
2632        >>> d.zoneHierarchyLevel('l5Z')
2633        5
2634        >>> d.zoneHierarchyLevel('madeup')
2635        Traceback (most recent call last):
2636        ...
2637        exploration.core.MissingZoneError...
2638        """
2639        info = self.getZoneInfo(zone)
2640        if info is None:
2641            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
2642
2643        return info.level
2644
2645    def zoneParents(
2646        self,
2647        zoneOrDecision: Union[base.Zone, base.DecisionID]
2648    ) -> Set[base.Zone]:
2649        """
2650        Returns the set of all zones which directly contain the target
2651        zone or decision.
2652
2653        Raises a `MissingDecisionError` if the target is neither a valid
2654        zone nor a valid decision.
2655
2656        Returns a copy, not a live editable set.
2657
2658        Example:
2659
2660        >>> g = DecisionGraph()
2661        >>> g.addDecision('A')
2662        0
2663        >>> g.addDecision('B')
2664        1
2665        >>> g.createZone('level0', 0)
2666        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2667 annotations=[])
2668        >>> g.createZone('level1', 1)
2669        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2670 annotations=[])
2671        >>> g.createZone('level2', 2)
2672        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2673 annotations=[])
2674        >>> g.createZone('level3', 3)
2675        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2676 annotations=[])
2677        >>> g.addDecisionToZone('A', 'level0')
2678        >>> g.addDecisionToZone('B', 'level0')
2679        >>> g.addZoneToZone('level0', 'level1')
2680        >>> g.addZoneToZone('level1', 'level2')
2681        >>> g.addZoneToZone('level2', 'level3')
2682        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2683        >>> sorted(g.zoneParents(0))
2684        ['level0']
2685        >>> sorted(g.zoneParents(1))
2686        ['level0', 'level2']
2687        """
2688        if zoneOrDecision in self.zones:
2689            zoneOrDecision = cast(base.Zone, zoneOrDecision)
2690            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
2691            return copy.copy(info.parents)
2692        elif zoneOrDecision in self:
2693            return self.nodes[zoneOrDecision].get('zones', set())
2694        else:
2695            raise MissingDecisionError(
2696                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
2697                f" valid decision."
2698            )
2699
2700    def zoneAncestors(
2701        self,
2702        zoneOrDecision: Union[base.Zone, base.DecisionID],
2703        exclude: Set[base.Zone] = set()
2704    ) -> Set[base.Zone]:
2705        """
2706        Returns the set of zones which contain the target zone or
2707        decision, either directly or indirectly. The target is not
2708        included in the set.
2709
2710        Any ones listed in the `exclude` set are also excluded, as are
2711        any of their ancestors which are not also ancestors of the
2712        target zone via another path of inclusion.
2713
2714        Raises a `MissingDecisionError` if the target is nether a valid
2715        zone nor a valid decision.
2716
2717        Example:
2718
2719        >>> g = DecisionGraph()
2720        >>> g.addDecision('A')
2721        0
2722        >>> g.addDecision('B')
2723        1
2724        >>> g.createZone('level0', 0)
2725        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2726 annotations=[])
2727        >>> g.createZone('level1', 1)
2728        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2729 annotations=[])
2730        >>> g.createZone('level2', 2)
2731        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2732 annotations=[])
2733        >>> g.createZone('level3', 3)
2734        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2735 annotations=[])
2736        >>> g.addDecisionToZone('A', 'level0')
2737        >>> g.addDecisionToZone('B', 'level0')
2738        >>> g.addZoneToZone('level0', 'level1')
2739        >>> g.addZoneToZone('level1', 'level2')
2740        >>> g.addZoneToZone('level2', 'level3')
2741        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2742        >>> sorted(g.zoneAncestors(0))
2743        ['level0', 'level1', 'level2', 'level3']
2744        >>> sorted(g.zoneAncestors(1))
2745        ['level0', 'level1', 'level2', 'level3']
2746        >>> sorted(g.zoneParents(0))
2747        ['level0']
2748        >>> sorted(g.zoneParents(1))
2749        ['level0', 'level2']
2750        """
2751        # Copy is important here!
2752        result = set(self.zoneParents(zoneOrDecision))
2753        result -= exclude
2754        for parent in copy.copy(result):
2755            # Recursively dig up ancestors, but exclude
2756            # results-so-far to avoid re-enumerating when there are
2757            # multiple braided inclusion paths.
2758            result |= self.zoneAncestors(parent, result | exclude)
2759
2760        return result
2761
2762    def zoneEdges(self, zone: base.Zone) -> Optional[
2763        Tuple[
2764            Set[Tuple[base.DecisionID, base.Transition]],
2765            Set[Tuple[base.DecisionID, base.Transition]]
2766        ]
2767    ]:
2768        """
2769        Given a zone to look at, finds all of the transitions which go
2770        out of and into that zone, ignoring internal transitions between
2771        decisions in the zone. This includes all decisions in sub-zones.
2772        The return value is a pair of sets for outgoing and then
2773        incoming transitions, where each transition is specified as a
2774        (sourceID, transitionName) pair.
2775
2776        Returns `None` if the target zone isn't yet fully defined.
2777
2778        Note that this takes time proportional to *all* edges plus *all*
2779        nodes in the graph no matter how large or small the zone in
2780        question is.
2781
2782        >>> g = DecisionGraph()
2783        >>> g.addDecision('A')
2784        0
2785        >>> g.addDecision('B')
2786        1
2787        >>> g.addDecision('C')
2788        2
2789        >>> g.addDecision('D')
2790        3
2791        >>> g.addTransition('A', 'up', 'B', 'down')
2792        >>> g.addTransition('B', 'right', 'C', 'left')
2793        >>> g.addTransition('C', 'down', 'D', 'up')
2794        >>> g.addTransition('D', 'left', 'A', 'right')
2795        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
2796        >>> g.createZone('Z', 0)
2797        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2798 annotations=[])
2799        >>> g.createZone('ZZ', 1)
2800        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2801 annotations=[])
2802        >>> g.addZoneToZone('Z', 'ZZ')
2803        >>> g.addDecisionToZone('A', 'Z')
2804        >>> g.addDecisionToZone('B', 'Z')
2805        >>> g.addDecisionToZone('D', 'ZZ')
2806        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
2807        >>> sorted(outgoing)
2808        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
2809        >>> sorted(incoming)
2810        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
2811        >>> outgoing, incoming = g.zoneEdges('ZZ')
2812        >>> sorted(outgoing)
2813        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
2814        >>> sorted(incoming)
2815        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
2816        >>> g.zoneEdges('madeup') is None
2817        True
2818        """
2819        # Find the interior nodes
2820        try:
2821            interior = self.allDecisionsInZone(zone)
2822        except MissingZoneError:
2823            return None
2824
2825        # Set up our result
2826        results: Tuple[
2827            Set[Tuple[base.DecisionID, base.Transition]],
2828            Set[Tuple[base.DecisionID, base.Transition]]
2829        ] = (set(), set())
2830
2831        # Because finding incoming edges requires searching the entire
2832        # graph anyways, it's more efficient to just consider each edge
2833        # once.
2834        for fromDecision in self:
2835            fromThere = self[fromDecision]
2836            for toDecision in fromThere:
2837                for transition in fromThere[toDecision]:
2838                    sourceIn = fromDecision in interior
2839                    destIn = toDecision in interior
2840                    if sourceIn and not destIn:
2841                        results[0].add((fromDecision, transition))
2842                    elif destIn and not sourceIn:
2843                        results[1].add((fromDecision, transition))
2844
2845        return results
2846
2847    def replaceZonesInHierarchy(
2848        self,
2849        target: base.AnyDecisionSpecifier,
2850        zone: base.Zone,
2851        level: int
2852    ) -> None:
2853        """
2854        This method replaces one or more zones which contain the
2855        specified `target` decision with a specific zone, at a specific
2856        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
2857        named zone doesn't yet exist, it will be created.
2858
2859        To do this, it looks at all zones which contain the target
2860        decision directly or indirectly (see `zoneAncestors`) and which
2861        are at the specified level.
2862
2863        - Any direct children of those zones which are ancestors of the
2864            target decision are removed from those zones and placed into
2865            the new zone instead, regardless of their levels. Indirect
2866            children are not affected (except perhaps indirectly via
2867            their parents' ancestors changing).
2868        - The new zone is placed into every direct parent of those
2869            zones, regardless of their levels (those parents are by
2870            definition all ancestors of the target decision).
2871        - If there were no zones at the target level, every zone at the
2872            next level down which is an ancestor of the target decision
2873            (or just that decision if the level is 0) is placed into the
2874            new zone as a direct child (and is removed from any previous
2875            parents it had). In this case, the new zone will also be
2876            added as a sub-zone to every ancestor of the target decision
2877            at the level above the specified level, if there are any.
2878            * In this case, if there are no zones at the level below the
2879                specified level, the highest level of zones smaller than
2880                that is treated as the level below, down to targeting
2881                the decision itself.
2882            * Similarly, if there are no zones at the level above the
2883                specified level but there are zones at a higher level,
2884                the new zone will be added to each of the zones in the
2885                lowest level above the target level that has zones in it.
2886
2887        A `MissingDecisionError` will be raised if the specified
2888        decision is not valid, or if the decision is left as default but
2889        there is no current decision in the exploration.
2890
2891        An `InvalidLevelError` will be raised if the level is less than
2892        zero.
2893
2894        Example:
2895
2896        >>> g = DecisionGraph()
2897        >>> g.addDecision('decision')
2898        0
2899        >>> g.addDecision('alternate')
2900        1
2901        >>> g.createZone('zone0', 0)
2902        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2903 annotations=[])
2904        >>> g.createZone('zone1', 1)
2905        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2906 annotations=[])
2907        >>> g.createZone('zone2.1', 2)
2908        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2909 annotations=[])
2910        >>> g.createZone('zone2.2', 2)
2911        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2912 annotations=[])
2913        >>> g.createZone('zone3', 3)
2914        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2915 annotations=[])
2916        >>> g.addDecisionToZone('decision', 'zone0')
2917        >>> g.addDecisionToZone('alternate', 'zone0')
2918        >>> g.addZoneToZone('zone0', 'zone1')
2919        >>> g.addZoneToZone('zone1', 'zone2.1')
2920        >>> g.addZoneToZone('zone1', 'zone2.2')
2921        >>> g.addZoneToZone('zone2.1', 'zone3')
2922        >>> g.addZoneToZone('zone2.2', 'zone3')
2923        >>> g.zoneHierarchyLevel('zone0')
2924        0
2925        >>> g.zoneHierarchyLevel('zone1')
2926        1
2927        >>> g.zoneHierarchyLevel('zone2.1')
2928        2
2929        >>> g.zoneHierarchyLevel('zone2.2')
2930        2
2931        >>> g.zoneHierarchyLevel('zone3')
2932        3
2933        >>> sorted(g.decisionsInZone('zone0'))
2934        [0, 1]
2935        >>> sorted(g.zoneAncestors('zone0'))
2936        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
2937        >>> g.subZones('zone1')
2938        {'zone0'}
2939        >>> g.zoneParents('zone0')
2940        {'zone1'}
2941        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
2942        >>> g.zoneParents('zone0')
2943        {'zone1'}
2944        >>> g.zoneParents('new0')
2945        {'zone1'}
2946        >>> sorted(g.zoneAncestors('zone0'))
2947        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
2948        >>> sorted(g.zoneAncestors('new0'))
2949        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
2950        >>> g.decisionsInZone('zone0')
2951        {1}
2952        >>> g.decisionsInZone('new0')
2953        {0}
2954        >>> sorted(g.subZones('zone1'))
2955        ['new0', 'zone0']
2956        >>> g.zoneParents('new0')
2957        {'zone1'}
2958        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
2959        >>> sorted(g.zoneAncestors(0))
2960        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
2961        >>> g.subZones('zone1')
2962        {'zone0'}
2963        >>> g.subZones('new1')
2964        {'new0'}
2965        >>> g.zoneParents('new0')
2966        {'new1'}
2967        >>> sorted(g.zoneParents('zone1'))
2968        ['zone2.1', 'zone2.2']
2969        >>> sorted(g.zoneParents('new1'))
2970        ['zone2.1', 'zone2.2']
2971        >>> g.zoneParents('zone2.1')
2972        {'zone3'}
2973        >>> g.zoneParents('zone2.2')
2974        {'zone3'}
2975        >>> sorted(g.subZones('zone2.1'))
2976        ['new1', 'zone1']
2977        >>> sorted(g.subZones('zone2.2'))
2978        ['new1', 'zone1']
2979        >>> sorted(g.allDecisionsInZone('zone2.1'))
2980        [0, 1]
2981        >>> sorted(g.allDecisionsInZone('zone2.2'))
2982        [0, 1]
2983        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
2984        >>> g.zoneParents('zone2.1')
2985        {'zone3'}
2986        >>> g.zoneParents('zone2.2')
2987        {'zone3'}
2988        >>> g.subZones('zone2.1')
2989        {'zone1'}
2990        >>> g.subZones('zone2.2')
2991        {'zone1'}
2992        >>> g.subZones('new2')
2993        {'new1'}
2994        >>> g.zoneParents('new2')
2995        {'zone3'}
2996        >>> g.allDecisionsInZone('zone2.1')
2997        {1}
2998        >>> g.allDecisionsInZone('zone2.2')
2999        {1}
3000        >>> g.allDecisionsInZone('new2')
3001        {0}
3002        >>> sorted(g.subZones('zone3'))
3003        ['new2', 'zone2.1', 'zone2.2']
3004        >>> g.zoneParents('zone3')
3005        set()
3006        >>> sorted(g.allDecisionsInZone('zone3'))
3007        [0, 1]
3008        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
3009        >>> sorted(g.subZones('zone3'))
3010        ['zone2.1', 'zone2.2']
3011        >>> g.subZones('new3')
3012        {'new2'}
3013        >>> g.zoneParents('zone3')
3014        set()
3015        >>> g.zoneParents('new3')
3016        set()
3017        >>> g.allDecisionsInZone('zone3')
3018        {1}
3019        >>> g.allDecisionsInZone('new3')
3020        {0}
3021        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
3022        >>> g.subZones('new4')
3023        {'new3'}
3024        >>> g.zoneHierarchyLevel('new4')
3025        5
3026
3027        Another example of level collapse when trying to replace a zone
3028        at a level above :
3029
3030        >>> g = DecisionGraph()
3031        >>> g.addDecision('A')
3032        0
3033        >>> g.addDecision('B')
3034        1
3035        >>> g.createZone('level0', 0)
3036        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3037 annotations=[])
3038        >>> g.createZone('level1', 1)
3039        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3040 annotations=[])
3041        >>> g.createZone('level2', 2)
3042        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3043 annotations=[])
3044        >>> g.createZone('level3', 3)
3045        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3046 annotations=[])
3047        >>> g.addDecisionToZone('B', 'level0')
3048        >>> g.addZoneToZone('level0', 'level1')
3049        >>> g.addZoneToZone('level1', 'level2')
3050        >>> g.addZoneToZone('level2', 'level3')
3051        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
3052        >>> g.zoneHierarchyLevel('level3')
3053        3
3054        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
3055        >>> g.zoneHierarchyLevel('newFirst')
3056        1
3057        >>> g.decisionsInZone('newFirst')
3058        {0}
3059        >>> g.decisionsInZone('level3')
3060        set()
3061        >>> sorted(g.allDecisionsInZone('level3'))
3062        [0, 1]
3063        >>> g.subZones('newFirst')
3064        set()
3065        >>> sorted(g.subZones('level3'))
3066        ['level2', 'newFirst']
3067        >>> g.zoneParents('newFirst')
3068        {'level3'}
3069        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
3070        >>> g.zoneHierarchyLevel('newSecond')
3071        2
3072        >>> g.decisionsInZone('newSecond')
3073        set()
3074        >>> g.allDecisionsInZone('newSecond')
3075        {0}
3076        >>> g.subZones('newSecond')
3077        {'newFirst'}
3078        >>> g.zoneParents('newSecond')
3079        {'level3'}
3080        >>> g.zoneParents('newFirst')
3081        {'newSecond'}
3082        >>> sorted(g.subZones('level3'))
3083        ['level2', 'newSecond']
3084        """
3085        tID = self.resolveDecision(target)
3086
3087        if level < 0:
3088            raise InvalidLevelError(
3089                f"Target level must be positive (got {level})."
3090            )
3091
3092        info = self.getZoneInfo(zone)
3093        if info is None:
3094            info = self.createZone(zone, level)
3095        elif level != info.level:
3096            raise InvalidLevelError(
3097                f"Target level ({level}) does not match the level of"
3098                f" the target zone ({zone!r} at level {info.level})."
3099            )
3100
3101        # Collect both parents & ancestors
3102        parents = self.zoneParents(tID)
3103        ancestors = set(self.zoneAncestors(tID))
3104
3105        # Map from levels to sets of zones from the ancestors pool
3106        levelMap: Dict[int, Set[base.Zone]] = {}
3107        highest = -1
3108        for ancestor in ancestors:
3109            ancestorLevel = self.zoneHierarchyLevel(ancestor)
3110            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
3111            if ancestorLevel > highest:
3112                highest = ancestorLevel
3113
3114        # Figure out if we have target zones to replace or not
3115        reparentDecision = False
3116        if level in levelMap:
3117            # If there are zones at the target level,
3118            targetZones = levelMap[level]
3119
3120            above = set()
3121            below = set()
3122
3123            for replaced in targetZones:
3124                above |= self.zoneParents(replaced)
3125                below |= self.subZones(replaced)
3126                if replaced in parents:
3127                    reparentDecision = True
3128
3129            # Only ancestors should be reparented
3130            below &= ancestors
3131
3132        else:
3133            # Find levels w/ zones in them above + below
3134            levelBelow = level - 1
3135            levelAbove = level + 1
3136            below = levelMap.get(levelBelow, set())
3137            above = levelMap.get(levelAbove, set())
3138
3139            while len(below) == 0 and levelBelow > 0:
3140                levelBelow -= 1
3141                below = levelMap.get(levelBelow, set())
3142
3143            if len(below) == 0:
3144                reparentDecision = True
3145
3146            while len(above) == 0 and levelAbove < highest:
3147                levelAbove += 1
3148                above = levelMap.get(levelAbove, set())
3149
3150        # Handle re-parenting zones below
3151        for under in below:
3152            for parent in self.zoneParents(under):
3153                if parent in ancestors:
3154                    self.removeZoneFromZone(under, parent)
3155            self.addZoneToZone(under, zone)
3156
3157        # Add this zone to each parent
3158        for parent in above:
3159            self.addZoneToZone(zone, parent)
3160
3161        # Re-parent the decision itself if necessary
3162        if reparentDecision:
3163            # (using set() here to avoid size-change-during-iteration)
3164            for parent in set(parents):
3165                self.removeDecisionFromZone(tID, parent)
3166            self.addDecisionToZone(tID, zone)
3167
3168    def getReciprocal(
3169        self,
3170        decision: base.AnyDecisionSpecifier,
3171        transition: base.Transition
3172    ) -> Optional[base.Transition]:
3173        """
3174        Returns the reciprocal edge for the specified transition from the
3175        specified decision (see `setReciprocal`). Returns
3176        `None` if no reciprocal has been established for that
3177        transition, or if that decision or transition does not exist.
3178        """
3179        dID = self.resolveDecision(decision)
3180
3181        dest = self.getDestination(dID, transition)
3182        if dest is not None:
3183            info = cast(
3184                TransitionProperties,
3185                self.edges[dID, dest, transition]  # type:ignore
3186            )
3187            recip = info.get("reciprocal")
3188            if recip is not None and not isinstance(recip, base.Transition):
3189                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
3190            return recip
3191        else:
3192            return None
3193
3194    def setReciprocal(
3195        self,
3196        decision: base.AnyDecisionSpecifier,
3197        transition: base.Transition,
3198        reciprocal: Optional[base.Transition],
3199        setBoth: bool = True,
3200        cleanup: bool = True
3201    ) -> None:
3202        """
3203        Sets the 'reciprocal' transition for a particular transition from
3204        a particular decision, and removes the reciprocal property from
3205        any old reciprocal transition.
3206
3207        Raises a `MissingDecisionError` or a `MissingTransitionError` if
3208        the specified decision or transition does not exist.
3209
3210        Raises an `InvalidDestinationError` if the reciprocal transition
3211        does not exist, or if it does exist but does not lead back to
3212        the decision the transition came from.
3213
3214        If `setBoth` is True (the default) then the transition which is
3215        being identified as a reciprocal will also have its reciprocal
3216        property set, pointing back to the primary transition being
3217        modified, and any old reciprocal of that transition will have its
3218        reciprocal set to None. If you want to create a situation with
3219        non-exclusive reciprocals, use `setBoth=False`.
3220
3221        If `cleanup` is True (the default) then abandoned reciprocal
3222        transitions (for both edges if `setBoth` was true) have their
3223        reciprocal properties removed. Set `cleanup` to false if you want
3224        to retain them, although this will result in non-exclusive
3225        reciprocal relationships.
3226
3227        If the `reciprocal` value is None, this deletes the reciprocal
3228        value entirely, and if `setBoth` is true, it does this for the
3229        previous reciprocal edge as well. No error is raised in this case
3230        when there was not already a reciprocal to delete.
3231
3232        Note that one should remove a reciprocal relationship before
3233        redirecting either edge of the pair in a way that gives it a new
3234        reciprocal, since otherwise, a later attempt to remove the
3235        reciprocal with `setBoth` set to True (the default) will end up
3236        deleting the reciprocal information from the other edge that was
3237        already modified. There is no way to reliably detect and avoid
3238        this, because two different decisions could (and often do in
3239        practice) have transitions with identical names, meaning that the
3240        reciprocal value will still be the same, but it will indicate a
3241        different edge in virtue of the destination of the edge changing.
3242
3243        ## Example
3244
3245        >>> g = DecisionGraph()
3246        >>> g.addDecision('G')
3247        0
3248        >>> g.addDecision('H')
3249        1
3250        >>> g.addDecision('I')
3251        2
3252        >>> g.addTransition('G', 'up', 'H', 'down')
3253        >>> g.addTransition('G', 'next', 'H', 'prev')
3254        >>> g.addTransition('H', 'next', 'I', 'prev')
3255        >>> g.addTransition('H', 'return', 'G')
3256        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
3257        Traceback (most recent call last):
3258        ...
3259        exploration.core.InvalidDestinationError...
3260        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
3261        Traceback (most recent call last):
3262        ...
3263        exploration.core.MissingTransitionError...
3264        >>> g.getReciprocal('G', 'up')
3265        'down'
3266        >>> g.getReciprocal('H', 'down')
3267        'up'
3268        >>> g.getReciprocal('H', 'return') is None
3269        True
3270        >>> g.setReciprocal('G', 'up', 'return')
3271        >>> g.getReciprocal('G', 'up')
3272        'return'
3273        >>> g.getReciprocal('H', 'down') is None
3274        True
3275        >>> g.getReciprocal('H', 'return')
3276        'up'
3277        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
3278        >>> g.getReciprocal('G', 'up') is None
3279        True
3280        >>> g.getReciprocal('H', 'down') is None
3281        True
3282        >>> g.getReciprocal('H', 'return') is None
3283        True
3284        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
3285        >>> g.getReciprocal('G', 'up')
3286        'down'
3287        >>> g.getReciprocal('H', 'down') is None
3288        True
3289        >>> g.getReciprocal('H', 'return') is None
3290        True
3291        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
3292        >>> g.getReciprocal('G', 'up')
3293        'down'
3294        >>> g.getReciprocal('H', 'down') is None
3295        True
3296        >>> g.getReciprocal('H', 'return')
3297        'up'
3298        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
3299        >>> g.getReciprocal('G', 'up')
3300        'down'
3301        >>> g.getReciprocal('H', 'down')
3302        'up'
3303        >>> g.getReciprocal('H', 'return') # unchanged
3304        'up'
3305        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
3306        >>> g.getReciprocal('G', 'up')
3307        'return'
3308        >>> g.getReciprocal('H', 'down')
3309        'up'
3310        >>> g.getReciprocal('H', 'return') # unchanged
3311        'up'
3312        >>> # Cleanup only applies to reciprocal if setBoth is true
3313        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
3314        >>> g.getReciprocal('G', 'up')
3315        'return'
3316        >>> g.getReciprocal('H', 'down')
3317        'up'
3318        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
3319        'up'
3320        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
3321        >>> g.getReciprocal('G', 'up')
3322        'down'
3323        >>> g.getReciprocal('H', 'down')
3324        'up'
3325        >>> g.getReciprocal('H', 'return') is None # cleaned up
3326        True
3327        """
3328        dID = self.resolveDecision(decision)
3329
3330        dest = self.destination(dID, transition) # possible KeyError
3331        if reciprocal is None:
3332            rDest = None
3333        else:
3334            rDest = self.getDestination(dest, reciprocal)
3335
3336        # Set or delete reciprocal property
3337        if reciprocal is None:
3338            # Delete the property
3339            info = self.edges[dID, dest, transition]  # type:ignore
3340
3341            old = info.pop('reciprocal')
3342            if setBoth:
3343                rDest = self.getDestination(dest, old)
3344                if rDest != dID:
3345                    raise RuntimeError(
3346                        f"Invalid reciprocal {old!r} for transition"
3347                        f" {transition!r} from {self.identityOf(dID)}:"
3348                        f" destination is {rDest}."
3349                    )
3350                rInfo = self.edges[dest, dID, old]  # type:ignore
3351                if 'reciprocal' in rInfo:
3352                    del rInfo['reciprocal']
3353        else:
3354            # Set the property, checking for errors first
3355            if rDest is None:
3356                raise MissingTransitionError(
3357                    f"Reciprocal transition {reciprocal!r} for"
3358                    f" transition {transition!r} from decision"
3359                    f" {self.identityOf(dID)} does not exist at"
3360                    f" decision {self.identityOf(dest)}"
3361                )
3362
3363            if rDest != dID:
3364                raise InvalidDestinationError(
3365                    f"Reciprocal transition {reciprocal!r} from"
3366                    f" decision {self.identityOf(dest)} does not lead"
3367                    f" back to decision {self.identityOf(dID)}."
3368                )
3369
3370            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
3371            abandoned = eProps.get('reciprocal')
3372            eProps['reciprocal'] = reciprocal
3373            if cleanup and abandoned not in (None, reciprocal):
3374                aProps = self.edges[dest, dID, abandoned]  # type:ignore
3375                if 'reciprocal' in aProps:
3376                    del aProps['reciprocal']
3377
3378            if setBoth:
3379                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
3380                revAbandoned = rProps.get('reciprocal')
3381                rProps['reciprocal'] = transition
3382                # Sever old reciprocal relationship
3383                if cleanup and revAbandoned not in (None, transition):
3384                    raProps = self.edges[
3385                        dID,  # type:ignore
3386                        dest,
3387                        revAbandoned
3388                    ]
3389                    del raProps['reciprocal']
3390
3391    def getReciprocalPair(
3392        self,
3393        decision: base.AnyDecisionSpecifier,
3394        transition: base.Transition
3395    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
3396        """
3397        Returns a tuple containing both the destination decision ID and
3398        the transition at that decision which is the reciprocal of the
3399        specified destination & transition. Returns `None` if no
3400        reciprocal has been established for that transition, or if that
3401        decision or transition does not exist.
3402
3403        >>> g = DecisionGraph()
3404        >>> g.addDecision('A')
3405        0
3406        >>> g.addDecision('B')
3407        1
3408        >>> g.addDecision('C')
3409        2
3410        >>> g.addTransition('A', 'up', 'B', 'down')
3411        >>> g.addTransition('B', 'right', 'C', 'left')
3412        >>> g.addTransition('A', 'oneway', 'C')
3413        >>> g.getReciprocalPair('A', 'up')
3414        (1, 'down')
3415        >>> g.getReciprocalPair('B', 'down')
3416        (0, 'up')
3417        >>> g.getReciprocalPair('B', 'right')
3418        (2, 'left')
3419        >>> g.getReciprocalPair('C', 'left')
3420        (1, 'right')
3421        >>> g.getReciprocalPair('C', 'up') is None
3422        True
3423        >>> g.getReciprocalPair('Q', 'up') is None
3424        True
3425        >>> g.getReciprocalPair('A', 'tunnel') is None
3426        True
3427        """
3428        try:
3429            dID = self.resolveDecision(decision)
3430        except MissingDecisionError:
3431            return None
3432
3433        reciprocal = self.getReciprocal(dID, transition)
3434        if reciprocal is None:
3435            return None
3436        else:
3437            destination = self.getDestination(dID, transition)
3438            if destination is None:
3439                return None
3440            else:
3441                return (destination, reciprocal)
3442
3443    def addDecision(
3444        self,
3445        name: base.DecisionName,
3446        domain: Optional[base.Domain] = None,
3447        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3448        annotations: Optional[List[base.Annotation]] = None
3449    ) -> base.DecisionID:
3450        """
3451        Adds a decision to the graph, without any transitions yet. Each
3452        decision will be assigned an ID so name collisions are allowed,
3453        but it's usually best to keep names unique at least within each
3454        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
3455        used for the decision's domain. A dictionary of tags and/or a
3456        list of annotations (strings in both cases) may be provided.
3457
3458        Returns the newly-assigned `DecisionID` for the decision it
3459        created.
3460
3461        Emits a `DecisionCollisionWarning` if a decision with the
3462        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
3463        global variable is set to `True`.
3464        """
3465        # Defaults
3466        if domain is None:
3467            domain = base.DEFAULT_DOMAIN
3468        if tags is None:
3469            tags = {}
3470        if annotations is None:
3471            annotations = []
3472
3473        # Error checking
3474        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3475            warnings.warn(
3476                (
3477                    f"Adding decision {name!r}: Another decision with"
3478                    f" that name already exists."
3479                ),
3480                DecisionCollisionWarning
3481            )
3482
3483        dID = self._assignID()
3484
3485        # Add the decision
3486        self.add_node(
3487            dID,
3488            name=name,
3489            domain=domain,
3490            tags=tags,
3491            annotations=annotations
3492        )
3493        #TODO: Elide tags/annotations if they're empty?
3494
3495        # Track it in our `nameLookup` dictionary
3496        self.nameLookup.setdefault(name, []).append(dID)
3497
3498        return dID
3499
3500    def addIdentifiedDecision(
3501        self,
3502        dID: base.DecisionID,
3503        name: base.DecisionName,
3504        domain: Optional[base.Domain] = None,
3505        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3506        annotations: Optional[List[base.Annotation]] = None
3507    ) -> None:
3508        """
3509        Adds a new decision to the graph using a specific decision ID,
3510        rather than automatically assigning a new decision ID like
3511        `addDecision` does. Otherwise works like `addDecision`.
3512
3513        Raises a `MechanismCollisionError` if the specified decision ID
3514        is already in use.
3515        """
3516        # Defaults
3517        if domain is None:
3518            domain = base.DEFAULT_DOMAIN
3519        if tags is None:
3520            tags = {}
3521        if annotations is None:
3522            annotations = []
3523
3524        # Error checking
3525        if dID in self.nodes:
3526            raise MechanismCollisionError(
3527                f"Cannot add a node with id {dID} and name {name!r}:"
3528                f" that ID is already used by node {self.identityOf(dID)}"
3529            )
3530
3531        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3532            warnings.warn(
3533                (
3534                    f"Adding decision {name!r}: Another decision with"
3535                    f" that name already exists."
3536                ),
3537                DecisionCollisionWarning
3538            )
3539
3540        # Add the decision
3541        self.add_node(
3542            dID,
3543            name=name,
3544            domain=domain,
3545            tags=tags,
3546            annotations=annotations
3547        )
3548        #TODO: Elide tags/annotations if they're empty?
3549
3550        # Track it in our `nameLookup` dictionary
3551        self.nameLookup.setdefault(name, []).append(dID)
3552
3553    def addTransition(
3554        self,
3555        fromDecision: base.AnyDecisionSpecifier,
3556        name: base.Transition,
3557        toDecision: base.AnyDecisionSpecifier,
3558        reciprocal: Optional[base.Transition] = None,
3559        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3560        annotations: Optional[List[base.Annotation]] = None,
3561        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
3562        revAnnotations: Optional[List[base.Annotation]] = None,
3563        requires: Optional[base.Requirement] = None,
3564        consequence: Optional[base.Consequence] = None,
3565        revRequires: Optional[base.Requirement] = None,
3566        revConsequece: Optional[base.Consequence] = None
3567    ) -> None:
3568        """
3569        Adds a transition connecting two decisions. A specifier for each
3570        decision is required, as is a name for the transition. If a
3571        `reciprocal` is provided, a reciprocal edge will be added in the
3572        opposite direction using that name; by default only the specified
3573        edge is added. A `TransitionCollisionError` will be raised if the
3574        `reciprocal` matches the name of an existing edge at the
3575        destination decision.
3576
3577        Both decisions must already exist, or a `MissingDecisionError`
3578        will be raised.
3579
3580        A dictionary of tags and/or a list of annotations may be
3581        provided. Tags and/or annotations for the reverse edge may also
3582        be specified if one is being added.
3583
3584        The `requires`, `consequence`, `revRequires`, and `revConsequece`
3585        arguments specify requirements and/or consequences of the new
3586        outgoing and reciprocal edges.
3587        """
3588        # Defaults
3589        if tags is None:
3590            tags = {}
3591        if annotations is None:
3592            annotations = []
3593        if revTags is None:
3594            revTags = {}
3595        if revAnnotations is None:
3596            revAnnotations = []
3597
3598        # Error checking
3599        fromID = self.resolveDecision(fromDecision)
3600        toID = self.resolveDecision(toDecision)
3601
3602        # Note: have to check this first so we don't add the forward edge
3603        # and then error out after a side effect!
3604        if (
3605            reciprocal is not None
3606        and self.getDestination(toDecision, reciprocal) is not None
3607        ):
3608            raise TransitionCollisionError(
3609                f"Cannot add a transition from"
3610                f" {self.identityOf(fromDecision)} to"
3611                f" {self.identityOf(toDecision)} with reciprocal edge"
3612                f" {reciprocal!r}: {reciprocal!r} is already used as an"
3613                f" edge name at {self.identityOf(toDecision)}."
3614            )
3615
3616        # Add the edge
3617        self.add_edge(
3618            fromID,
3619            toID,
3620            key=name,
3621            tags=tags,
3622            annotations=annotations
3623        )
3624        self.setTransitionRequirement(fromDecision, name, requires)
3625        if consequence is not None:
3626            self.setConsequence(fromDecision, name, consequence)
3627        if reciprocal is not None:
3628            # Add the reciprocal edge
3629            self.add_edge(
3630                toID,
3631                fromID,
3632                key=reciprocal,
3633                tags=revTags,
3634                annotations=revAnnotations
3635            )
3636            self.setReciprocal(fromID, name, reciprocal)
3637            self.setTransitionRequirement(
3638                toDecision,
3639                reciprocal,
3640                revRequires
3641            )
3642            if revConsequece is not None:
3643                self.setConsequence(toDecision, reciprocal, revConsequece)
3644
3645    def removeTransition(
3646        self,
3647        fromDecision: base.AnyDecisionSpecifier,
3648        transition: base.Transition,
3649        removeReciprocal=False
3650    ) -> Union[
3651        TransitionProperties,
3652        Tuple[TransitionProperties, TransitionProperties]
3653    ]:
3654        """
3655        Removes a transition. If `removeReciprocal` is true (False is the
3656        default) any reciprocal transition will also be removed (but no
3657        error will occur if there wasn't a reciprocal).
3658
3659        For each removed transition, *every* transition that targeted
3660        that transition as its reciprocal will have its reciprocal set to
3661        `None`, to avoid leaving any invalid reciprocal values.
3662
3663        Raises a `KeyError` if either the target decision or the target
3664        transition does not exist.
3665
3666        Returns a transition properties dictionary with the properties
3667        of the removed transition, or if `removeReciprocal` is true,
3668        returns a pair of such dictionaries for the target transition
3669        and its reciprocal.
3670
3671        ## Example
3672
3673        >>> g = DecisionGraph()
3674        >>> g.addDecision('A')
3675        0
3676        >>> g.addDecision('B')
3677        1
3678        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
3679        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
3680        >>> g.addTransition('A', 'next', 'B')
3681        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
3682        >>> p = g.removeTransition('A', 'up')
3683        >>> p['tags']
3684        {'wide'}
3685        >>> g.destinationsFrom('A')
3686        {'in': 1, 'next': 1}
3687        >>> g.destinationsFrom('B')
3688        {'down': 0, 'out': 0}
3689        >>> g.getReciprocal('B', 'down') is None
3690        True
3691        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
3692        'down'
3693        >>> g.getReciprocal('A', 'in') # not affected
3694        'out'
3695        >>> g.getReciprocal('B', 'out') # not affected
3696        'in'
3697        >>> # Now with removeReciprocal set to True
3698        >>> g.addTransition('A', 'up', 'B') # add this back in
3699        >>> g.setReciprocal('A', 'up', 'down') # sets both
3700        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
3701        >>> g.destinationsFrom('A')
3702        {'in': 1, 'next': 1}
3703        >>> g.destinationsFrom('B')
3704        {'out': 0}
3705        >>> g.getReciprocal('A', 'next') is None
3706        True
3707        >>> g.getReciprocal('A', 'in') # not affected
3708        'out'
3709        >>> g.getReciprocal('B', 'out') # not affected
3710        'in'
3711        >>> g.removeTransition('A', 'none')
3712        Traceback (most recent call last):
3713        ...
3714        exploration.core.MissingTransitionError...
3715        >>> g.removeTransition('Z', 'nope')
3716        Traceback (most recent call last):
3717        ...
3718        exploration.core.MissingDecisionError...
3719        """
3720        # Resolve target ID
3721        fromID = self.resolveDecision(fromDecision)
3722
3723        # raises if either is missing:
3724        destination = self.destination(fromID, transition)
3725        reciprocal = self.getReciprocal(fromID, transition)
3726
3727        # Get dictionaries of parallel & antiparallel edges to be
3728        # checked for invalid reciprocals after removing edges
3729        # Note: these will update live as we remove edges
3730        allAntiparallel = self[destination][fromID]
3731        allParallel = self[fromID][destination]
3732
3733        # Remove the target edge
3734        fProps = self.getTransitionProperties(fromID, transition)
3735        self.remove_edge(fromID, destination, transition)
3736
3737        # Clean up any dangling reciprocal values
3738        for tProps in allAntiparallel.values():
3739            if tProps.get('reciprocal') == transition:
3740                del tProps['reciprocal']
3741
3742        # Remove the reciprocal if requested
3743        if removeReciprocal and reciprocal is not None:
3744            rProps = self.getTransitionProperties(destination, reciprocal)
3745            self.remove_edge(destination, fromID, reciprocal)
3746
3747            # Clean up any dangling reciprocal values
3748            for tProps in allParallel.values():
3749                if tProps.get('reciprocal') == reciprocal:
3750                    del tProps['reciprocal']
3751
3752            return (fProps, rProps)
3753        else:
3754            return fProps
3755
3756    def addMechanism(
3757        self,
3758        name: base.MechanismName,
3759        where: Optional[base.AnyDecisionSpecifier] = None
3760    ) -> base.MechanismID:
3761        """
3762        Creates a new mechanism with the given name at the specified
3763        decision, returning its assigned ID. If `where` is `None`, it
3764        creates a global mechanism. Raises a `MechanismCollisionError`
3765        if a mechanism with the same name already exists at a specified
3766        decision (or already exists as a global mechanism).
3767
3768        Note that if the decision is deleted, the mechanism will be as
3769        well.
3770
3771        Since `MechanismState`s are not tracked by `DecisionGraph`s but
3772        instead are part of a `State`, the mechanism won't be in any
3773        particular state, which means it will be treated as being in the
3774        `base.DEFAULT_MECHANISM_STATE`.
3775        """
3776        if where is None:
3777            mechs = self.globalMechanisms
3778            dID = None
3779        else:
3780            dID = self.resolveDecision(where)
3781            mechs = self.nodes[dID].setdefault('mechanisms', {})
3782
3783        if name in mechs:
3784            if dID is None:
3785                raise MechanismCollisionError(
3786                    f"A global mechanism named {name!r} already exists."
3787                )
3788            else:
3789                raise MechanismCollisionError(
3790                    f"A mechanism named {name!r} already exists at"
3791                    f" decision {self.identityOf(dID)}."
3792                )
3793
3794        mID = self._assignMechanismID()
3795        mechs[name] = mID
3796        self.mechanisms[mID] = (dID, name)
3797        return mID
3798
3799    def mechanismsAt(
3800        self,
3801        decision: base.AnyDecisionSpecifier
3802    ) -> Dict[base.MechanismName, base.MechanismID]:
3803        """
3804        Returns a dictionary mapping mechanism names to their IDs for
3805        all mechanisms at the specified decision.
3806        """
3807        dID = self.resolveDecision(decision)
3808
3809        return self.nodes[dID]['mechanisms']
3810
3811    def mechanismDetails(
3812        self,
3813        mID: base.MechanismID
3814    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
3815        """
3816        Returns a tuple containing the decision ID and mechanism name
3817        for the specified mechanism. Returns `None` if there is no
3818        mechanism with that ID. For global mechanisms, `None` is used in
3819        place of a decision ID.
3820        """
3821        return self.mechanisms.get(mID)
3822
3823    def deleteMechanism(self, mID: base.MechanismID) -> None:
3824        """
3825        Deletes the specified mechanism.
3826        """
3827        name, dID = self.mechanisms.pop(mID)
3828
3829        del self.nodes[dID]['mechanisms'][name]
3830
3831    def localLookup(
3832        self,
3833        startFrom: Union[
3834            base.AnyDecisionSpecifier,
3835            Collection[base.AnyDecisionSpecifier]
3836        ],
3837        findAmong: Callable[
3838            ['DecisionGraph', Union[Set[base.DecisionID], str]],
3839            Optional[LookupResult]
3840        ],
3841        fallbackLayerName: Optional[str] = "fallback",
3842        fallbackToAllDecisions: bool = True
3843    ) -> Optional[LookupResult]:
3844        """
3845        Looks up some kind of result in the graph by starting from a
3846        base set of decisions and widening the search iteratively based
3847        on zones. This first searches for result(s) in the set of
3848        decisions given, then in the set of all decisions which are in
3849        level-0 zones containing those decisions, then in level-1 zones,
3850        etc. When it runs out of relevant zones, it will check all
3851        decisions which are in any domain that a decision from the
3852        initial search set is in, and then if `fallbackLayerName` is a
3853        string, it will provide that string instead of a set of decision
3854        IDs to the `findAmong` function as the next layer to search.
3855        After the `fallbackLayerName` is used, if
3856        `fallbackToAllDecisions` is `True` (the default) a final search
3857        will be run on all decisions in the graph. The provided
3858        `findAmong` function is called on each successive decision ID
3859        set, until it generates a non-`None` result. We stop and return
3860        that non-`None` result as soon as one is generated. But if none
3861        of the decision sets consulted generate non-`None` results, then
3862        the entire result will be `None`.
3863        """
3864        # Normalize starting decisions to a set
3865        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
3866            startFrom = set([startFrom])
3867
3868        # Resolve decision IDs; convert to list
3869        searchArea: Union[Set[base.DecisionID], str] = set(
3870            self.resolveDecision(spec) for spec in startFrom
3871        )
3872
3873        # Find all ancestor zones & all relevant domains
3874        allAncestors = set()
3875        relevantDomains = set()
3876        for startingDecision in searchArea:
3877            allAncestors |= self.zoneAncestors(startingDecision)
3878            relevantDomains.add(self.domainFor(startingDecision))
3879
3880        # Build layers dictionary
3881        ancestorLayers: Dict[int, Set[base.Zone]] = {}
3882        for zone in allAncestors:
3883            info = self.getZoneInfo(zone)
3884            assert info is not None
3885            level = info.level
3886            ancestorLayers.setdefault(level, set()).add(zone)
3887
3888        searchLayers: LookupLayersList = (
3889            cast(LookupLayersList, [None])
3890          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
3891          + cast(LookupLayersList, ["domains"])
3892        )
3893        if fallbackLayerName is not None:
3894            searchLayers.append("fallback")
3895
3896        if fallbackToAllDecisions:
3897            searchLayers.append("all")
3898
3899        # Continue our search through zone layers
3900        for layer in searchLayers:
3901            # Update search area on subsequent iterations
3902            if layer == "domains":
3903                searchArea = set()
3904                for relevant in relevantDomains:
3905                    searchArea |= self.allDecisionsInDomain(relevant)
3906            elif layer == "fallback":
3907                assert fallbackLayerName is not None
3908                searchArea = fallbackLayerName
3909            elif layer == "all":
3910                searchArea = set(self.nodes)
3911            elif layer is not None:
3912                layer = cast(int, layer)  # must be an integer
3913                searchZones = ancestorLayers[layer]
3914                searchArea = set()
3915                for zone in searchZones:
3916                    searchArea |= self.allDecisionsInZone(zone)
3917            # else it's the first iteration and we use the starting
3918            # searchArea
3919
3920            searchResult: Optional[LookupResult] = findAmong(
3921                self,
3922                searchArea
3923            )
3924
3925            if searchResult is not None:
3926                return searchResult
3927
3928        # Didn't find any non-None results.
3929        return None
3930
3931    @staticmethod
3932    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
3933        ['DecisionGraph', Union[Set[base.DecisionID], str]],
3934        Optional[base.MechanismID]
3935    ]:
3936        """
3937        Returns a search function that looks for the given mechanism ID,
3938        suitable for use with `localLookup`. The finder will raise a
3939        `MechanismCollisionError` if it finds more than one mechanism
3940        with the specified name at the same level of the search.
3941        """
3942        def namedMechanismFinder(
3943            graph: 'DecisionGraph',
3944            searchIn: Union[Set[base.DecisionID], str]
3945        ) -> Optional[base.MechanismID]:
3946            """
3947            Generated finder function for `localLookup` to find a unique
3948            mechanism by name.
3949            """
3950            candidates: List[base.DecisionID] = []
3951
3952            if searchIn == "fallback":
3953                if name in graph.globalMechanisms:
3954                    candidates = [graph.globalMechanisms[name]]
3955
3956            else:
3957                assert isinstance(searchIn, set)
3958                for dID in searchIn:
3959                    mechs = graph.nodes[dID].get('mechanisms', {})
3960                    if name in mechs:
3961                        candidates.append(mechs[name])
3962
3963            if len(candidates) > 1:
3964                raise MechanismCollisionError(
3965                    f"There are {len(candidates)} mechanisms named {name!r}"
3966                    f" in the search area ({len(searchIn)} decisions(s))."
3967                )
3968            elif len(candidates) == 1:
3969                return candidates[0]
3970            else:
3971                return None
3972
3973        return namedMechanismFinder
3974
3975    def lookupMechanism(
3976        self,
3977        startFrom: Union[
3978            base.AnyDecisionSpecifier,
3979            Collection[base.AnyDecisionSpecifier]
3980        ],
3981        name: base.MechanismName
3982    ) -> base.MechanismID:
3983        """
3984        Looks up the mechanism with the given name 'closest' to the
3985        given decision or set of decisions. First it looks for a
3986        mechanism with that name that's at one of those decisions. Then
3987        it starts looking in level-0 zones which contain any of them,
3988        then in level-1 zones, and so on. If it finds two mechanisms
3989        with the target name during the same search pass, it raises a
3990        `MechanismCollisionError`, but if it finds one it returns it.
3991        Raises a `MissingMechanismError` if there is no mechanisms with
3992        that name among global mechanisms (searched after the last
3993        applicable level of zones) or anywhere in the graph (which is the
3994        final level of search after checking global mechanisms).
3995
3996        For example:
3997
3998        >>> d = DecisionGraph()
3999        >>> d.addDecision('A')
4000        0
4001        >>> d.addDecision('B')
4002        1
4003        >>> d.addDecision('C')
4004        2
4005        >>> d.addDecision('D')
4006        3
4007        >>> d.addDecision('E')
4008        4
4009        >>> d.addMechanism('switch', 'A')
4010        0
4011        >>> d.addMechanism('switch', 'B')
4012        1
4013        >>> d.addMechanism('switch', 'C')
4014        2
4015        >>> d.addMechanism('lever', 'D')
4016        3
4017        >>> d.addMechanism('lever', None)  # global
4018        4
4019        >>> d.createZone('Z1', 0)
4020        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4021 annotations=[])
4022        >>> d.createZone('Z2', 0)
4023        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4024 annotations=[])
4025        >>> d.createZone('Zup', 1)
4026        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
4027 annotations=[])
4028        >>> d.addDecisionToZone('A', 'Z1')
4029        >>> d.addDecisionToZone('B', 'Z1')
4030        >>> d.addDecisionToZone('C', 'Z2')
4031        >>> d.addDecisionToZone('D', 'Z2')
4032        >>> d.addDecisionToZone('E', 'Z1')
4033        >>> d.addZoneToZone('Z1', 'Zup')
4034        >>> d.addZoneToZone('Z2', 'Zup')
4035        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
4036        Traceback (most recent call last):
4037        ...
4038        exploration.core.MechanismCollisionError...
4039        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
4040        4
4041        >>> d.lookupMechanism({'D'}, 'lever')  # local
4042        3
4043        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
4044        3
4045        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
4046        3
4047        >>> d.lookupMechanism({'A'}, 'switch')  # local
4048        0
4049        >>> d.lookupMechanism({'B'}, 'switch')  # local
4050        1
4051        >>> d.lookupMechanism({'C'}, 'switch')  # local
4052        2
4053        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
4054        Traceback (most recent call last):
4055        ...
4056        exploration.core.MechanismCollisionError...
4057        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
4058        Traceback (most recent call last):
4059        ...
4060        exploration.core.MechanismCollisionError...
4061        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
4062        1
4063        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
4064        Traceback (most recent call last):
4065        ...
4066        exploration.core.MechanismCollisionError...
4067        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
4068        Traceback (most recent call last):
4069        ...
4070        exploration.core.MechanismCollisionError...
4071        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
4072        2
4073        """
4074        result = self.localLookup(
4075            startFrom,
4076            DecisionGraph.uniqueMechanismFinder(name)
4077        )
4078        if result is None:
4079            raise MissingMechanismError(
4080                f"No mechanism named {name!r}"
4081            )
4082        else:
4083            return result
4084
4085    def resolveMechanism(
4086        self,
4087        specifier: base.AnyMechanismSpecifier,
4088        startFrom: Union[
4089            None,
4090            base.AnyDecisionSpecifier,
4091            Collection[base.AnyDecisionSpecifier]
4092        ] = None
4093    ) -> base.MechanismID:
4094        """
4095        Works like `lookupMechanism`, except it accepts a
4096        `base.AnyMechanismSpecifier` which may have position information
4097        baked in, and so the `startFrom` information is optional. If
4098        position information isn't specified in the mechanism specifier
4099        and startFrom is not provided, the mechanism is searched for at
4100        the global scope and then in the entire graph. On the other
4101        hand, if the specifier includes any position information, the
4102        startFrom value provided here will be ignored.
4103        """
4104        if isinstance(specifier, base.MechanismID):
4105            return specifier
4106
4107        elif isinstance(specifier, base.MechanismName):
4108            if startFrom is None:
4109                startFrom = set()
4110            return self.lookupMechanism(startFrom, specifier)
4111
4112        elif isinstance(specifier, tuple) and len(specifier) == 4:
4113            domain, zone, decision, mechanism = specifier
4114            if domain is None and zone is None and decision is None:
4115                if startFrom is None:
4116                    startFrom = set()
4117                return self.lookupMechanism(startFrom, mechanism)
4118
4119            elif decision is not None:
4120                startFrom = {
4121                    self.resolveDecision(
4122                        base.DecisionSpecifier(domain, zone, decision)
4123                    )
4124                }
4125                return self.lookupMechanism(startFrom, mechanism)
4126
4127            else:  # decision is None but domain and/or zone aren't
4128                startFrom = set()
4129                if zone is not None:
4130                    baseStart = self.allDecisionsInZone(zone)
4131                else:
4132                    baseStart = set(self)
4133
4134                if domain is None:
4135                    startFrom = baseStart
4136                else:
4137                    for dID in baseStart:
4138                        if self.domainFor(dID) == domain:
4139                            startFrom.add(dID)
4140                return self.lookupMechanism(startFrom, mechanism)
4141
4142        else:
4143            raise TypeError(
4144                f"Invalid mechanism specifier: {repr(specifier)}"
4145                f"\n(Must be a mechanism ID, mechanism name, or"
4146                f" mechanism specifier tuple)"
4147            )
4148
4149    def walkConsequenceMechanisms(
4150        self,
4151        consequence: base.Consequence,
4152        searchFrom: Set[base.DecisionID]
4153    ) -> Generator[base.MechanismID, None, None]:
4154        """
4155        Yields each requirement in the given `base.Consequence`,
4156        including those in `base.Condition`s, `base.ConditionalSkill`s
4157        within `base.Challenge`s, and those set or toggled by
4158        `base.Effect`s. The `searchFrom` argument specifies where to
4159        start searching for mechanisms, since requirements include them
4160        by name, not by ID.
4161        """
4162        for part in base.walkParts(consequence):
4163            if isinstance(part, dict):
4164                if 'skills' in part:  # a Challenge
4165                    for cSkill in part['skills'].walk():
4166                        if isinstance(cSkill, base.ConditionalSkill):
4167                            yield from self.walkRequirementMechanisms(
4168                                cSkill.requirement,
4169                                searchFrom
4170                            )
4171                elif 'condition' in part:  # a Condition
4172                    yield from self.walkRequirementMechanisms(
4173                        part['condition'],
4174                        searchFrom
4175                    )
4176                elif 'value' in part:  # an Effect
4177                    val = part['value']
4178                    if part['type'] == 'set':
4179                        if (
4180                            isinstance(val, tuple)
4181                        and len(val) == 2
4182                        and isinstance(val[1], base.State)
4183                        ):
4184                            yield from self.walkRequirementMechanisms(
4185                                base.ReqMechanism(val[0], val[1]),
4186                                searchFrom
4187                            )
4188                    elif part['type'] == 'toggle':
4189                        if isinstance(val, tuple):
4190                            assert len(val) == 2
4191                            yield from self.walkRequirementMechanisms(
4192                                base.ReqMechanism(val[0], '_'),
4193                                  # state part is ignored here
4194                                searchFrom
4195                            )
4196
4197    def walkRequirementMechanisms(
4198        self,
4199        req: base.Requirement,
4200        searchFrom: Set[base.DecisionID]
4201    ) -> Generator[base.MechanismID, None, None]:
4202        """
4203        Given a requirement, yields any mechanisms mentioned in that
4204        requirement, in depth-first traversal order.
4205        """
4206        for part in req.walk():
4207            if isinstance(part, base.ReqMechanism):
4208                mech = part.mechanism
4209                yield self.resolveMechanism(
4210                    mech,
4211                    startFrom=searchFrom
4212                )
4213
4214    def addUnexploredEdge(
4215        self,
4216        fromDecision: base.AnyDecisionSpecifier,
4217        name: base.Transition,
4218        destinationName: Optional[base.DecisionName] = None,
4219        reciprocal: Optional[base.Transition] = 'return',
4220        toDomain: Optional[base.Domain] = None,
4221        placeInZone: Union[base.Zone, type[base.DefaultZone], None] = None,
4222        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
4223        annotations: Optional[List[base.Annotation]] = None,
4224        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
4225        revAnnotations: Optional[List[base.Annotation]] = None,
4226        requires: Optional[base.Requirement] = None,
4227        consequence: Optional[base.Consequence] = None,
4228        revRequires: Optional[base.Requirement] = None,
4229        revConsequece: Optional[base.Consequence] = None
4230    ) -> base.DecisionID:
4231        """
4232        Adds a transition connecting to a new decision named `'_u.-n-'`
4233        where '-n-' is the number of unknown decisions (named or not)
4234        that have ever been created in this graph (or using the
4235        specified destination name if one is provided). This represents
4236        a transition to an unknown destination. The destination node
4237        gets tagged 'unconfirmed'.
4238
4239        This also adds a reciprocal transition in the reverse direction,
4240        unless `reciprocal` is set to `None`. The reciprocal will use
4241        the provided name (default is 'return'). The new decision will
4242        be in the same domain as the decision it's connected to, unless
4243        `toDecision` is specified, in which case it will be in that
4244        domain.
4245
4246        The new decision will not be placed into any zones, unless
4247        `placeInZone` is specified, in which case it will be placed into
4248        that zone. If that zone needs to be created, it will be created
4249        at level 0; in that case that zone will be added to any
4250        grandparent zones of the decision we're branching off of. If
4251        `placeInZone` is set to `base.DefaultZone`, then the new
4252        decision will be placed into each parent zone of the decision
4253        we're branching off of, as long as the new decision is in the
4254        same domain as the decision we're branching from (otherwise only
4255        an explicit `placeInZone` would apply).
4256
4257        The ID of the decision that was created is returned.
4258
4259        A `MissingDecisionError` will be raised if the starting decision
4260        does not exist, a `TransitionCollisionError` will be raised if
4261        it exists but already has a transition with the given name, and a
4262        `DecisionCollisionWarning` will be issued if a decision with the
4263        specified destination name already exists (won't happen when
4264        using an automatic name).
4265
4266        Lists of tags and/or annotations (strings in both cases) may be
4267        provided. These may also be provided for the reciprocal edge.
4268
4269        Similarly, requirements and/or consequences for either edge may
4270        be provided.
4271
4272        ## Example
4273
4274        >>> g = DecisionGraph()
4275        >>> g.addDecision('A')
4276        0
4277        >>> g.addUnexploredEdge('A', 'up')
4278        1
4279        >>> g.nameFor(1)
4280        '_u.0'
4281        >>> g.decisionTags(1)
4282        {'unconfirmed': 1}
4283        >>> g.addUnexploredEdge('A', 'right', 'B')
4284        2
4285        >>> g.nameFor(2)
4286        'B'
4287        >>> g.decisionTags(2)
4288        {'unconfirmed': 1}
4289        >>> g.addUnexploredEdge('A', 'down', None, 'up')
4290        3
4291        >>> g.nameFor(3)
4292        '_u.2'
4293        >>> g.addUnexploredEdge(
4294        ...    '_u.0',
4295        ...    'beyond',
4296        ...    toDomain='otherDomain',
4297        ...    tags={'fast':1},
4298        ...    revTags={'slow':1},
4299        ...    annotations=['comment'],
4300        ...    revAnnotations=['one', 'two'],
4301        ...    requires=base.ReqCapability('dash'),
4302        ...    revRequires=base.ReqCapability('super dash'),
4303        ...    consequence=[base.effect(gain='super dash')],
4304        ...    revConsequece=[base.effect(lose='super dash')]
4305        ... )
4306        4
4307        >>> g.nameFor(4)
4308        '_u.3'
4309        >>> g.domainFor(4)
4310        'otherDomain'
4311        >>> g.transitionTags('_u.0', 'beyond')
4312        {'fast': 1}
4313        >>> g.transitionAnnotations('_u.0', 'beyond')
4314        ['comment']
4315        >>> g.getTransitionRequirement('_u.0', 'beyond')
4316        ReqCapability('dash')
4317        >>> e = g.getConsequence('_u.0', 'beyond')
4318        >>> e == [base.effect(gain='super dash')]
4319        True
4320        >>> g.transitionTags('_u.3', 'return')
4321        {'slow': 1}
4322        >>> g.transitionAnnotations('_u.3', 'return')
4323        ['one', 'two']
4324        >>> g.getTransitionRequirement('_u.3', 'return')
4325        ReqCapability('super dash')
4326        >>> e = g.getConsequence('_u.3', 'return')
4327        >>> e == [base.effect(lose='super dash')]
4328        True
4329        """
4330        # Defaults
4331        if tags is None:
4332            tags = {}
4333        if annotations is None:
4334            annotations = []
4335        if revTags is None:
4336            revTags = {}
4337        if revAnnotations is None:
4338            revAnnotations = []
4339
4340        # Resolve ID
4341        fromID = self.resolveDecision(fromDecision)
4342        if toDomain is None:
4343            toDomain = self.domainFor(fromID)
4344
4345        if name in self.destinationsFrom(fromID):
4346            raise TransitionCollisionError(
4347                f"Cannot add a new edge {name!r}:"
4348                f" {self.identityOf(fromDecision)} already has an"
4349                f" outgoing edge with that name."
4350            )
4351
4352        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
4353            warnings.warn(
4354                (
4355                    f"Cannot add a new unexplored node"
4356                    f" {destinationName!r}: A decision with that name"
4357                    f" already exists.\n(Leave destinationName as None"
4358                    f" to use an automatic name.)"
4359                ),
4360                DecisionCollisionWarning
4361            )
4362
4363        # Create the new unexplored decision and add the edge
4364        if destinationName is None:
4365            toName = '_u.' + str(self.unknownCount)
4366        else:
4367            toName = destinationName
4368        self.unknownCount += 1
4369        newID = self.addDecision(toName, domain=toDomain)
4370        self.addTransition(
4371            fromID,
4372            name,
4373            newID,
4374            tags=tags,
4375            annotations=annotations
4376        )
4377        self.setTransitionRequirement(fromID, name, requires)
4378        if consequence is not None:
4379            self.setConsequence(fromID, name, consequence)
4380
4381        # Add it to a zone if requested
4382        if (
4383            placeInZone is base.DefaultZone
4384        and toDomain == self.domainFor(fromID)
4385        ):
4386            # Add to each parent of the from decision
4387            for parent in self.zoneParents(fromID):
4388                self.addDecisionToZone(newID, parent)
4389        elif placeInZone is not None:
4390            # Otherwise add it to one specific zone, creating that zone
4391            # at level 0 if necessary
4392            assert isinstance(placeInZone, base.Zone)
4393            if self.getZoneInfo(placeInZone) is None:
4394                self.createZone(placeInZone, 0)
4395                # Add new zone to each grandparent of the from decision
4396                for parent in self.zoneParents(fromID):
4397                    for grandparent in self.zoneParents(parent):
4398                        self.addZoneToZone(placeInZone, grandparent)
4399            self.addDecisionToZone(newID, placeInZone)
4400
4401        # Create the reciprocal edge
4402        if reciprocal is not None:
4403            self.addTransition(
4404                newID,
4405                reciprocal,
4406                fromID,
4407                tags=revTags,
4408                annotations=revAnnotations
4409            )
4410            self.setTransitionRequirement(newID, reciprocal, revRequires)
4411            if revConsequece is not None:
4412                self.setConsequence(newID, reciprocal, revConsequece)
4413            # Set as a reciprocal
4414            self.setReciprocal(fromID, name, reciprocal)
4415
4416        # Tag the destination as 'unconfirmed'
4417        self.tagDecision(newID, 'unconfirmed')
4418
4419        # Return ID of new destination
4420        return newID
4421
4422    def retargetTransition(
4423        self,
4424        fromDecision: base.AnyDecisionSpecifier,
4425        transition: base.Transition,
4426        newDestination: base.AnyDecisionSpecifier,
4427        swapReciprocal=True,
4428        errorOnNameColision=True
4429    ) -> Optional[base.Transition]:
4430        """
4431        Given a particular decision and a transition at that decision,
4432        changes that transition so that it goes to the specified new
4433        destination instead of wherever it was connected to before. If
4434        the new destination is the same as the old one, no changes are
4435        made.
4436
4437        If `swapReciprocal` is set to True (the default) then any
4438        reciprocal edge at the old destination will be deleted, and a
4439        new reciprocal edge from the new destination with equivalent
4440        properties to the original reciprocal will be created, pointing
4441        to the origin of the specified transition. If `swapReciprocal`
4442        is set to False, then the reciprocal relationship with any old
4443        reciprocal edge will be removed, but the old reciprocal edge
4444        will not be changed.
4445
4446        Note that if `errorOnNameColision` is True (the default), then
4447        if the reciprocal transition has the same name as a transition
4448        which already exists at the new destination node, a
4449        `TransitionCollisionError` will be thrown. However, if it is set
4450        to False, the reciprocal transition will be renamed with a suffix
4451        to avoid any possible name collisions. Either way, the name of
4452        the reciprocal transition (possibly just changed) will be
4453        returned, or None if there was no reciprocal transition.
4454
4455        ## Example
4456
4457        >>> g = DecisionGraph()
4458        >>> for fr, to, nm in [
4459        ...     ('A', 'B', 'up'),
4460        ...     ('A', 'B', 'up2'),
4461        ...     ('B', 'A', 'down'),
4462        ...     ('B', 'B', 'self'),
4463        ...     ('B', 'C', 'next'),
4464        ...     ('C', 'B', 'prev')
4465        ... ]:
4466        ...     if g.getDecision(fr) is None:
4467        ...        g.addDecision(fr)
4468        ...     if g.getDecision(to) is None:
4469        ...         g.addDecision(to)
4470        ...     g.addTransition(fr, nm, to)
4471        0
4472        1
4473        2
4474        >>> g.setReciprocal('A', 'up', 'down')
4475        >>> g.setReciprocal('B', 'next', 'prev')
4476        >>> g.destination('A', 'up')
4477        1
4478        >>> g.destination('B', 'down')
4479        0
4480        >>> g.retargetTransition('A', 'up', 'C')
4481        'down'
4482        >>> g.destination('A', 'up')
4483        2
4484        >>> g.getDestination('B', 'down') is None
4485        True
4486        >>> g.destination('C', 'down')
4487        0
4488        >>> g.addTransition('A', 'next', 'B')
4489        >>> g.addTransition('B', 'prev', 'A')
4490        >>> g.setReciprocal('A', 'next', 'prev')
4491        >>> # Can't swap a reciprocal in a way that would collide names
4492        >>> g.getReciprocal('C', 'prev')
4493        'next'
4494        >>> g.retargetTransition('C', 'prev', 'A')
4495        Traceback (most recent call last):
4496        ...
4497        exploration.core.TransitionCollisionError...
4498        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
4499        'next'
4500        >>> g.destination('C', 'prev')
4501        0
4502        >>> g.destination('A', 'next') # not changed
4503        1
4504        >>> # Reciprocal relationship is severed:
4505        >>> g.getReciprocal('C', 'prev') is None
4506        True
4507        >>> g.getReciprocal('B', 'next') is None
4508        True
4509        >>> # Swap back so we can do another demo
4510        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
4511        >>> # Note return value was None here because there was no reciprocal
4512        >>> g.setReciprocal('C', 'prev', 'next')
4513        >>> # Swap reciprocal by renaming it
4514        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
4515        'next.1'
4516        >>> g.getReciprocal('C', 'prev')
4517        'next.1'
4518        >>> g.destination('C', 'prev')
4519        0
4520        >>> g.destination('A', 'next.1')
4521        2
4522        >>> g.destination('A', 'next')
4523        1
4524        >>> # Note names are the same but these are from different nodes
4525        >>> g.getReciprocal('A', 'next')
4526        'prev'
4527        >>> g.getReciprocal('A', 'next.1')
4528        'prev'
4529        """
4530        fromID = self.resolveDecision(fromDecision)
4531        newDestID = self.resolveDecision(newDestination)
4532
4533        # Figure out the old destination of the transition we're swapping
4534        oldDestID = self.destination(fromID, transition)
4535        reciprocal = self.getReciprocal(fromID, transition)
4536
4537        # If thew new destination is the same, we don't do anything!
4538        if oldDestID == newDestID:
4539            return reciprocal
4540
4541        # First figure out reciprocal business so we can error out
4542        # without making changes if we need to
4543        if swapReciprocal and reciprocal is not None:
4544            reciprocal = self.rebaseTransition(
4545                oldDestID,
4546                reciprocal,
4547                newDestID,
4548                swapReciprocal=False,
4549                errorOnNameColision=errorOnNameColision
4550            )
4551
4552        # Handle the forward transition...
4553        # Find the transition properties
4554        tProps = self.getTransitionProperties(fromID, transition)
4555
4556        # Delete the edge
4557        self.removeEdgeByKey(fromID, transition)
4558
4559        # Add the new edge
4560        self.addTransition(fromID, transition, newDestID)
4561
4562        # Reapply the transition properties
4563        self.setTransitionProperties(fromID, transition, **tProps)
4564
4565        # Handle the reciprocal transition if there is one...
4566        if reciprocal is not None:
4567            if not swapReciprocal:
4568                # Then sever the relationship, but only if that edge
4569                # still exists (we might be in the middle of a rebase)
4570                check = self.getDestination(oldDestID, reciprocal)
4571                if check is not None:
4572                    self.setReciprocal(
4573                        oldDestID,
4574                        reciprocal,
4575                        None,
4576                        setBoth=False # Other transition was deleted already
4577                    )
4578            else:
4579                # Establish new reciprocal relationship
4580                self.setReciprocal(
4581                    fromID,
4582                    transition,
4583                    reciprocal
4584                )
4585
4586        return reciprocal
4587
4588    def rebaseTransition(
4589        self,
4590        fromDecision: base.AnyDecisionSpecifier,
4591        transition: base.Transition,
4592        newBase: base.AnyDecisionSpecifier,
4593        swapReciprocal=True,
4594        errorOnNameColision=True
4595    ) -> base.Transition:
4596        """
4597        Given a particular destination and a transition at that
4598        destination, changes that transition's origin to a new base
4599        decision. If the new source is the same as the old one, no
4600        changes are made.
4601
4602        If `swapReciprocal` is set to True (the default) then any
4603        reciprocal edge at the destination will be retargeted to point
4604        to the new source so that it can remain a reciprocal. If
4605        `swapReciprocal` is set to False, then the reciprocal
4606        relationship with any old reciprocal edge will be removed, but
4607        the old reciprocal edge will not be otherwise changed.
4608
4609        Note that if `errorOnNameColision` is True (the default), then
4610        if the transition has the same name as a transition which
4611        already exists at the new source node, a
4612        `TransitionCollisionError` will be raised. However, if it is set
4613        to False, the transition will be renamed with a suffix to avoid
4614        any possible name collisions. Either way, the (possibly new) name
4615        of the transition that was rebased will be returned.
4616
4617        ## Example
4618
4619        >>> g = DecisionGraph()
4620        >>> for fr, to, nm in [
4621        ...     ('A', 'B', 'up'),
4622        ...     ('A', 'B', 'up2'),
4623        ...     ('B', 'A', 'down'),
4624        ...     ('B', 'B', 'self'),
4625        ...     ('B', 'C', 'next'),
4626        ...     ('C', 'B', 'prev')
4627        ... ]:
4628        ...     if g.getDecision(fr) is None:
4629        ...        g.addDecision(fr)
4630        ...     if g.getDecision(to) is None:
4631        ...         g.addDecision(to)
4632        ...     g.addTransition(fr, nm, to)
4633        0
4634        1
4635        2
4636        >>> g.setReciprocal('A', 'up', 'down')
4637        >>> g.setReciprocal('B', 'next', 'prev')
4638        >>> g.destination('A', 'up')
4639        1
4640        >>> g.destination('B', 'down')
4641        0
4642        >>> g.rebaseTransition('B', 'down', 'C')
4643        'down'
4644        >>> g.destination('A', 'up')
4645        2
4646        >>> g.getDestination('B', 'down') is None
4647        True
4648        >>> g.destination('C', 'down')
4649        0
4650        >>> g.addTransition('A', 'next', 'B')
4651        >>> g.addTransition('B', 'prev', 'A')
4652        >>> g.setReciprocal('A', 'next', 'prev')
4653        >>> # Can't rebase in a way that would collide names
4654        >>> g.rebaseTransition('B', 'next', 'A')
4655        Traceback (most recent call last):
4656        ...
4657        exploration.core.TransitionCollisionError...
4658        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
4659        'next.1'
4660        >>> g.destination('C', 'prev')
4661        0
4662        >>> g.destination('A', 'next') # not changed
4663        1
4664        >>> # Collision is avoided by renaming
4665        >>> g.destination('A', 'next.1')
4666        2
4667        >>> # Swap without reciprocal
4668        >>> g.getReciprocal('A', 'next.1')
4669        'prev'
4670        >>> g.getReciprocal('C', 'prev')
4671        'next.1'
4672        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
4673        'next.1'
4674        >>> g.getReciprocal('C', 'prev') is None
4675        True
4676        >>> g.destination('C', 'prev')
4677        0
4678        >>> g.getDestination('A', 'next.1') is None
4679        True
4680        >>> g.destination('A', 'next')
4681        1
4682        >>> g.destination('B', 'next.1')
4683        2
4684        >>> g.getReciprocal('B', 'next.1') is None
4685        True
4686        >>> # Rebase in a way that creates a self-edge
4687        >>> g.rebaseTransition('A', 'next', 'B')
4688        'next'
4689        >>> g.getDestination('A', 'next') is None
4690        True
4691        >>> g.destination('B', 'next')
4692        1
4693        >>> g.destination('B', 'prev') # swapped as a reciprocal
4694        1
4695        >>> g.getReciprocal('B', 'next') # still reciprocals
4696        'prev'
4697        >>> g.getReciprocal('B', 'prev')
4698        'next'
4699        >>> # And rebasing of a self-edge also works
4700        >>> g.rebaseTransition('B', 'prev', 'A')
4701        'prev'
4702        >>> g.destination('A', 'prev')
4703        1
4704        >>> g.destination('B', 'next')
4705        0
4706        >>> g.getReciprocal('B', 'next') # still reciprocals
4707        'prev'
4708        >>> g.getReciprocal('A', 'prev')
4709        'next'
4710        >>> # We've effectively reversed this edge/reciprocal pair
4711        >>> # by rebasing twice
4712        """
4713        fromID = self.resolveDecision(fromDecision)
4714        newBaseID = self.resolveDecision(newBase)
4715
4716        # If thew new base is the same, we don't do anything!
4717        if newBaseID == fromID:
4718            return transition
4719
4720        # First figure out reciprocal business so we can swap it later
4721        # without making changes if we need to
4722        destination = self.destination(fromID, transition)
4723        reciprocal = self.getReciprocal(fromID, transition)
4724        # Check for an already-deleted reciprocal
4725        if (
4726            reciprocal is not None
4727        and self.getDestination(destination, reciprocal) is None
4728        ):
4729            reciprocal = None
4730
4731        # Handle the base swap...
4732        # Find the transition properties
4733        tProps = self.getTransitionProperties(fromID, transition)
4734
4735        # Check for a collision
4736        targetDestinations = self.destinationsFrom(newBaseID)
4737        if transition in targetDestinations:
4738            if errorOnNameColision:
4739                raise TransitionCollisionError(
4740                    f"Cannot rebase transition {transition!r} from"
4741                    f" {self.identityOf(fromDecision)}: it would be a"
4742                    f" duplicate transition name at the new base"
4743                    f" decision {self.identityOf(newBase)}."
4744                )
4745            else:
4746                # Figure out a good fresh name
4747                newName = utils.uniqueName(
4748                    transition,
4749                    targetDestinations
4750                )
4751        else:
4752            newName = transition
4753
4754        # Delete the edge
4755        self.removeEdgeByKey(fromID, transition)
4756
4757        # Add the new edge
4758        self.addTransition(newBaseID, newName, destination)
4759
4760        # Reapply the transition properties
4761        self.setTransitionProperties(newBaseID, newName, **tProps)
4762
4763        # Handle the reciprocal transition if there is one...
4764        if reciprocal is not None:
4765            if not swapReciprocal:
4766                # Then sever the relationship
4767                self.setReciprocal(
4768                    destination,
4769                    reciprocal,
4770                    None,
4771                    setBoth=False # Other transition was deleted already
4772                )
4773            else:
4774                # Otherwise swap the reciprocal edge
4775                self.retargetTransition(
4776                    destination,
4777                    reciprocal,
4778                    newBaseID,
4779                    swapReciprocal=False
4780                )
4781
4782                # And establish a new reciprocal relationship
4783                self.setReciprocal(
4784                    newBaseID,
4785                    newName,
4786                    reciprocal
4787                )
4788
4789        # Return the new name in case it was changed
4790        return newName
4791
4792    # TODO: zone merging!
4793
4794    # TODO: Double-check that exploration vars get updated when this is
4795    # called!
4796    def mergeDecisions(
4797        self,
4798        merge: base.AnyDecisionSpecifier,
4799        mergeInto: base.AnyDecisionSpecifier,
4800        errorOnNameColision=True
4801    ) -> Dict[base.Transition, base.Transition]:
4802        """
4803        Merges two decisions, deleting the first after transferring all
4804        of its incoming and outgoing edges to target the second one,
4805        whose name is retained. The second decision will be added to any
4806        zones that the first decision was a member of. If either decision
4807        does not exist, a `MissingDecisionError` will be raised. If
4808        `merge` and `mergeInto` are the same, then nothing will be
4809        changed.
4810
4811        Unless `errorOnNameColision` is set to False, a
4812        `TransitionCollisionError` will be raised if the two decisions
4813        have outgoing transitions with the same name. If
4814        `errorOnNameColision` is set to False, then such edges will be
4815        renamed using a suffix to avoid name collisions, with edges
4816        connected to the second decision retaining their original names
4817        and edges that were connected to the first decision getting
4818        renamed.
4819
4820        Any mechanisms located at the first decision will be moved to the
4821        merged decision.
4822
4823        The tags and annotations of the merged decision are added to the
4824        tags and annotations of the merge target. If there are shared
4825        tags, the values from the merge target will override those of
4826        the merged decision. If this is undesired behavior, clear/edit
4827        the tags/annotations of the merged decision before the merge.
4828
4829        The 'unconfirmed' tag is treated specially: if both decisions have
4830        it it will be retained, but otherwise it will be dropped even if
4831        one of the situations had it before.
4832
4833        The domain of the second decision is retained.
4834
4835        Returns a dictionary mapping each original transition name to
4836        its new name in cases where transitions get renamed; this will
4837        be empty when no re-naming occurs, including when
4838        `errorOnNameColision` is True. If there were any transitions
4839        connecting the nodes that were merged, these become self-edges
4840        of the merged node (and may be renamed if necessary).
4841        Note that all renamed transitions were originally based on the
4842        first (merged) node, since transitions of the second (merge
4843        target) node are not renamed.
4844
4845        ## Example
4846
4847        >>> g = DecisionGraph()
4848        >>> for fr, to, nm in [
4849        ...     ('A', 'B', 'up'),
4850        ...     ('A', 'B', 'up2'),
4851        ...     ('B', 'A', 'down'),
4852        ...     ('B', 'B', 'self'),
4853        ...     ('B', 'C', 'next'),
4854        ...     ('C', 'B', 'prev'),
4855        ...     ('A', 'C', 'right')
4856        ... ]:
4857        ...     if g.getDecision(fr) is None:
4858        ...        g.addDecision(fr)
4859        ...     if g.getDecision(to) is None:
4860        ...         g.addDecision(to)
4861        ...     g.addTransition(fr, nm, to)
4862        0
4863        1
4864        2
4865        >>> g.getDestination('A', 'up')
4866        1
4867        >>> g.getDestination('B', 'down')
4868        0
4869        >>> sorted(g)
4870        [0, 1, 2]
4871        >>> g.setReciprocal('A', 'up', 'down')
4872        >>> g.setReciprocal('B', 'next', 'prev')
4873        >>> g.mergeDecisions('C', 'B')
4874        {}
4875        >>> g.destinationsFrom('A')
4876        {'up': 1, 'up2': 1, 'right': 1}
4877        >>> g.destinationsFrom('B')
4878        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
4879        >>> 'C' in g
4880        False
4881        >>> g.mergeDecisions('A', 'A') # does nothing
4882        {}
4883        >>> # Can't merge non-existent decision
4884        >>> g.mergeDecisions('A', 'Z')
4885        Traceback (most recent call last):
4886        ...
4887        exploration.core.MissingDecisionError...
4888        >>> g.mergeDecisions('Z', 'A')
4889        Traceback (most recent call last):
4890        ...
4891        exploration.core.MissingDecisionError...
4892        >>> # Can't merge decisions w/ shared edge names
4893        >>> g.addDecision('D')
4894        3
4895        >>> g.addTransition('D', 'next', 'A')
4896        >>> g.addTransition('A', 'prev', 'D')
4897        >>> g.setReciprocal('D', 'next', 'prev')
4898        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
4899        Traceback (most recent call last):
4900        ...
4901        exploration.core.TransitionCollisionError...
4902        >>> # Auto-rename colliding edges
4903        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
4904        {'next': 'next.1'}
4905        >>> g.destination('B', 'next') # merge target unchanged
4906        1
4907        >>> g.destination('B', 'next.1') # merged decision name changed
4908        0
4909        >>> g.destination('B', 'prev') # name unchanged (no collision)
4910        1
4911        >>> g.getReciprocal('B', 'next') # unchanged (from B)
4912        'prev'
4913        >>> g.getReciprocal('B', 'next.1') # from A
4914        'prev'
4915        >>> g.getReciprocal('A', 'prev') # from B
4916        'next.1'
4917
4918        ## Folding four nodes into a 2-node loop
4919
4920        >>> g = DecisionGraph()
4921        >>> g.addDecision('X')
4922        0
4923        >>> g.addDecision('Y')
4924        1
4925        >>> g.addTransition('X', 'next', 'Y', 'prev')
4926        >>> g.addDecision('preX')
4927        2
4928        >>> g.addDecision('postY')
4929        3
4930        >>> g.addTransition('preX', 'next', 'X', 'prev')
4931        >>> g.addTransition('Y', 'next', 'postY', 'prev')
4932        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
4933        {'next': 'next.1'}
4934        >>> g.destinationsFrom('X')
4935        {'next': 1, 'prev': 1}
4936        >>> g.destinationsFrom('Y')
4937        {'prev': 0, 'next': 3, 'next.1': 0}
4938        >>> 2 in g
4939        False
4940        >>> g.destinationsFrom('postY')
4941        {'prev': 1}
4942        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
4943        {'prev': 'prev.1'}
4944        >>> g.destinationsFrom('X')
4945        {'next': 1, 'prev': 1, 'prev.1': 1}
4946        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
4947        {'prev': 0, 'next.1': 0, 'next': 0}
4948        >>> 2 in g
4949        False
4950        >>> 3 in g
4951        False
4952        >>> # Reciprocals are tangled...
4953        >>> g.getReciprocal(0, 'prev')
4954        'next.1'
4955        >>> g.getReciprocal(0, 'prev.1')
4956        'next'
4957        >>> g.getReciprocal(1, 'next')
4958        'prev.1'
4959        >>> g.getReciprocal(1, 'next.1')
4960        'prev'
4961        >>> # Note: one merge cannot handle both extra transitions
4962        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
4963        >>> # (It would merge both edges but the result would retain
4964        >>> # 'next.1' instead of retaining 'next'.)
4965        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
4966        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
4967        >>> g.destinationsFrom('X')
4968        {'next': 1, 'prev': 1}
4969        >>> g.destinationsFrom('Y')
4970        {'prev': 0, 'next': 0}
4971        >>> # Reciprocals were salvaged in second merger
4972        >>> g.getReciprocal('X', 'prev')
4973        'next'
4974        >>> g.getReciprocal('Y', 'next')
4975        'prev'
4976
4977        ## Merging with tags/requirements/annotations/consequences
4978
4979        >>> g = DecisionGraph()
4980        >>> g.addDecision('X')
4981        0
4982        >>> g.addDecision('Y')
4983        1
4984        >>> g.addDecision('Z')
4985        2
4986        >>> g.addTransition('X', 'next', 'Y', 'prev')
4987        >>> g.addTransition('X', 'down', 'Z', 'up')
4988        >>> g.tagDecision('X', 'tag0', 1)
4989        >>> g.tagDecision('Y', 'tag1', 10)
4990        >>> g.tagDecision('Y', 'unconfirmed')
4991        >>> g.tagDecision('Z', 'tag1', 20)
4992        >>> g.tagDecision('Z', 'tag2', 30)
4993        >>> g.tagTransition('X', 'next', 'ttag1', 11)
4994        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
4995        >>> g.tagTransition('X', 'down', 'ttag3', 33)
4996        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
4997        >>> g.annotateDecision('Y', 'annotation 1')
4998        >>> g.annotateDecision('Z', 'annotation 2')
4999        >>> g.annotateDecision('Z', 'annotation 3')
5000        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
5001        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
5002        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
5003        >>> g.setTransitionRequirement(
5004        ...     'X',
5005        ...     'next',
5006        ...     base.ReqCapability('power')
5007        ... )
5008        >>> g.setTransitionRequirement(
5009        ...     'Y',
5010        ...     'prev',
5011        ...     base.ReqTokens('token', 1)
5012        ... )
5013        >>> g.setTransitionRequirement(
5014        ...     'X',
5015        ...     'down',
5016        ...     base.ReqCapability('power2')
5017        ... )
5018        >>> g.setTransitionRequirement(
5019        ...     'Z',
5020        ...     'up',
5021        ...     base.ReqTokens('token2', 2)
5022        ... )
5023        >>> g.setConsequence(
5024        ...     'Y',
5025        ...     'prev',
5026        ...     [base.effect(gain="power2")]
5027        ... )
5028        >>> g.mergeDecisions('Y', 'Z')
5029        {}
5030        >>> g.destination('X', 'next')
5031        2
5032        >>> g.destination('X', 'down')
5033        2
5034        >>> g.destination('Z', 'prev')
5035        0
5036        >>> g.destination('Z', 'up')
5037        0
5038        >>> g.decisionTags('X')
5039        {'tag0': 1}
5040        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
5041        {'tag1': 20, 'tag2': 30}
5042        >>> g.transitionTags('X', 'next')
5043        {'ttag1': 11}
5044        >>> g.transitionTags('X', 'down')
5045        {'ttag3': 33}
5046        >>> g.transitionTags('Z', 'prev')
5047        {'ttag2': 22}
5048        >>> g.transitionTags('Z', 'up')
5049        {'ttag4': 44}
5050        >>> g.decisionAnnotations('Z')
5051        ['annotation 2', 'annotation 3', 'annotation 1']
5052        >>> g.transitionAnnotations('Z', 'prev')
5053        ['trans annotation 1', 'trans annotation 2']
5054        >>> g.transitionAnnotations('Z', 'up')
5055        ['trans annotation 3']
5056        >>> g.getTransitionRequirement('X', 'next')
5057        ReqCapability('power')
5058        >>> g.getTransitionRequirement('Z', 'prev')
5059        ReqTokens('token', 1)
5060        >>> g.getTransitionRequirement('X', 'down')
5061        ReqCapability('power2')
5062        >>> g.getTransitionRequirement('Z', 'up')
5063        ReqTokens('token2', 2)
5064        >>> g.getConsequence('Z', 'prev') == [
5065        ...     {
5066        ...         'type': 'gain',
5067        ...         'applyTo': 'active',
5068        ...         'value': 'power2',
5069        ...         'charges': None,
5070        ...         'delay': None,
5071        ...         'hidden': False
5072        ...     }
5073        ... ]
5074        True
5075
5076        ## Merging into node without tags
5077
5078        >>> g = DecisionGraph()
5079        >>> g.addDecision('X')
5080        0
5081        >>> g.addDecision('Y')
5082        1
5083        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
5084        >>> g.tagDecision('Y', 'tag', 'value')
5085        >>> g.mergeDecisions('Y', 'X')
5086        {}
5087        >>> g.decisionTags('X')
5088        {'tag': 'value'}
5089        >>> 0 in g  # Second argument remains
5090        True
5091        >>> 1 in g  # First argument is deleted
5092        False
5093        """
5094        # Resolve IDs
5095        mergeID = self.resolveDecision(merge)
5096        mergeIntoID = self.resolveDecision(mergeInto)
5097
5098        # Create our result as an empty dictionary
5099        result: Dict[base.Transition, base.Transition] = {}
5100
5101        # Short-circuit if the two decisions are the same
5102        if mergeID == mergeIntoID:
5103            return result
5104
5105        # MissingDecisionErrors from here if either doesn't exist
5106        allNewOutgoing = set(self.destinationsFrom(mergeID))
5107        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
5108        # Find colliding transition names
5109        collisions = allNewOutgoing & allOldOutgoing
5110        if len(collisions) > 0 and errorOnNameColision:
5111            raise TransitionCollisionError(
5112                f"Cannot merge decision {self.identityOf(merge)} into"
5113                f" decision {self.identityOf(mergeInto)}: the decisions"
5114                f" share {len(collisions)} transition names:"
5115                f" {collisions}\n(Note that errorOnNameColision was set"
5116                f" to True, set it to False to allow the operation by"
5117                f" renaming half of those transitions.)"
5118            )
5119
5120        # Record zones that will have to change after the merge
5121        zoneParents = self.zoneParents(mergeID)
5122
5123        # First, swap all incoming edges, along with their reciprocals
5124        # This will include self-edges, which will be retargeted and
5125        # whose reciprocals will be rebased in the process, leading to
5126        # the possibility of a missing edge during the loop
5127        for source, incoming in self.allEdgesTo(mergeID):
5128            # Skip this edge if it was already swapped away because it's
5129            # a self-loop with a reciprocal whose reciprocal was
5130            # processed earlier in the loop
5131            if incoming not in self.destinationsFrom(source):
5132                continue
5133
5134            # Find corresponding outgoing edge
5135            outgoing = self.getReciprocal(source, incoming)
5136
5137            # Swap both edges to new destination
5138            newOutgoing = self.retargetTransition(
5139                source,
5140                incoming,
5141                mergeIntoID,
5142                swapReciprocal=True,
5143                errorOnNameColision=False # collisions were detected above
5144            )
5145            # Add to our result if the name of the reciprocal was
5146            # changed
5147            if (
5148                outgoing is not None
5149            and newOutgoing is not None
5150            and outgoing != newOutgoing
5151            ):
5152                result[outgoing] = newOutgoing
5153
5154        # Next, swap any remaining outgoing edges (which didn't have
5155        # reciprocals, or they'd already be swapped, unless they were
5156        # self-edges previously). Note that in this loop, there can't be
5157        # any self-edges remaining, although there might be connections
5158        # between the merging nodes that need to become self-edges
5159        # because they used to be a self-edge that was half-retargeted
5160        # by the previous loop.
5161        # Note: a copy is used here to avoid iterating over a changing
5162        # dictionary
5163        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
5164            newOutgoing = self.rebaseTransition(
5165                mergeID,
5166                stillOutgoing,
5167                mergeIntoID,
5168                swapReciprocal=True,
5169                errorOnNameColision=False # collisions were detected above
5170            )
5171            if stillOutgoing != newOutgoing:
5172                result[stillOutgoing] = newOutgoing
5173
5174        # At this point, there shouldn't be any remaining incoming or
5175        # outgoing edges!
5176        assert self.degree(mergeID) == 0
5177
5178        # Merge tags & annotations
5179        # Note that these operations affect the underlying graph
5180        destTags = self.decisionTags(mergeIntoID)
5181        destUnvisited = 'unconfirmed' in destTags
5182        sourceTags = self.decisionTags(mergeID)
5183        sourceUnvisited = 'unconfirmed' in sourceTags
5184        # Copy over only new tags, leaving existing tags alone
5185        for key in sourceTags:
5186            if key not in destTags:
5187                destTags[key] = sourceTags[key]
5188
5189        if int(destUnvisited) + int(sourceUnvisited) == 1:
5190            del destTags['unconfirmed']
5191
5192        self.decisionAnnotations(mergeIntoID).extend(
5193            self.decisionAnnotations(mergeID)
5194        )
5195
5196        # Transfer zones
5197        for zone in zoneParents:
5198            self.addDecisionToZone(mergeIntoID, zone)
5199
5200        # Delete the old node
5201        self.removeDecision(mergeID)
5202
5203        return result
5204
5205    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
5206        """
5207        Deletes the specified decision from the graph, updating
5208        attendant structures like zones. Note that the ID of the deleted
5209        node will NOT be reused, unless it's specifically provided to
5210        `addIdentifiedDecision`.
5211
5212        For example:
5213
5214        >>> dg = DecisionGraph()
5215        >>> dg.addDecision('A')
5216        0
5217        >>> dg.addDecision('B')
5218        1
5219        >>> list(dg)
5220        [0, 1]
5221        >>> 1 in dg
5222        True
5223        >>> 'B' in dg.nameLookup
5224        True
5225        >>> dg.removeDecision('B')
5226        >>> 1 in dg
5227        False
5228        >>> list(dg)
5229        [0]
5230        >>> 'B' in dg.nameLookup
5231        False
5232        >>> dg.addDecision('C')  # doesn't re-use ID
5233        2
5234        """
5235        dID = self.resolveDecision(decision)
5236
5237        # Remove the target from all zones:
5238        for zone in self.zones:
5239            self.removeDecisionFromZone(dID, zone)
5240
5241        # Remove the node but record the current name
5242        name = self.nodes[dID]['name']
5243        self.remove_node(dID)
5244
5245        # Clean up the nameLookup entry
5246        luInfo = self.nameLookup[name]
5247        luInfo.remove(dID)
5248        if len(luInfo) == 0:
5249            self.nameLookup.pop(name)
5250
5251        # TODO: Clean up edges?
5252
5253    def renameDecision(
5254        self,
5255        decision: base.AnyDecisionSpecifier,
5256        newName: base.DecisionName
5257    ):
5258        """
5259        Renames a decision. The decision retains its old ID.
5260
5261        Generates a `DecisionCollisionWarning` if a decision using the new
5262        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
5263
5264        Example:
5265
5266        >>> g = DecisionGraph()
5267        >>> g.addDecision('one')
5268        0
5269        >>> g.addDecision('three')
5270        1
5271        >>> g.addTransition('one', '>', 'three')
5272        >>> g.addTransition('three', '<', 'one')
5273        >>> g.tagDecision('three', 'hi')
5274        >>> g.annotateDecision('three', 'note')
5275        >>> g.destination('one', '>')
5276        1
5277        >>> g.destination('three', '<')
5278        0
5279        >>> g.renameDecision('three', 'two')
5280        >>> g.resolveDecision('one')
5281        0
5282        >>> g.resolveDecision('two')
5283        1
5284        >>> g.resolveDecision('three')
5285        Traceback (most recent call last):
5286        ...
5287        exploration.core.MissingDecisionError...
5288        >>> g.destination('one', '>')
5289        1
5290        >>> g.nameFor(1)
5291        'two'
5292        >>> g.getDecision('three') is None
5293        True
5294        >>> g.destination('two', '<')
5295        0
5296        >>> g.decisionTags('two')
5297        {'hi': 1}
5298        >>> g.decisionAnnotations('two')
5299        ['note']
5300        """
5301        dID = self.resolveDecision(decision)
5302
5303        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
5304            warnings.warn(
5305                (
5306                    f"Can't rename {self.identityOf(decision)} as"
5307                    f" {newName!r} because a decision with that name"
5308                    f" already exists."
5309                ),
5310                DecisionCollisionWarning
5311            )
5312
5313        # Update name in node
5314        oldName = self.nodes[dID]['name']
5315        self.nodes[dID]['name'] = newName
5316
5317        # Update nameLookup entries
5318        oldNL = self.nameLookup[oldName]
5319        oldNL.remove(dID)
5320        if len(oldNL) == 0:
5321            self.nameLookup.pop(oldName)
5322        self.nameLookup.setdefault(newName, []).append(dID)
5323
5324    def mergeTransitions(
5325        self,
5326        fromDecision: base.AnyDecisionSpecifier,
5327        merge: base.Transition,
5328        mergeInto: base.Transition,
5329        mergeReciprocal=True
5330    ) -> None:
5331        """
5332        Given a decision and two transitions that start at that decision,
5333        merges the first transition into the second transition, combining
5334        their transition properties (using `mergeProperties`) and
5335        deleting the first transition. By default any reciprocal of the
5336        first transition is also merged into the reciprocal of the
5337        second, although you can set `mergeReciprocal` to `False` to
5338        disable this in which case the old reciprocal will lose its
5339        reciprocal relationship, even if the transition that was merged
5340        into does not have a reciprocal.
5341
5342        If the two names provided are the same, nothing will happen.
5343
5344        If the two transitions do not share the same destination, they
5345        cannot be merged, and an `InvalidDestinationError` will result.
5346        Use `retargetTransition` beforehand to ensure that they do if you
5347        want to merge transitions with different destinations.
5348
5349        A `MissingDecisionError` or `MissingTransitionError` will result
5350        if the decision or either transition does not exist.
5351
5352        If merging reciprocal properties was requested and the first
5353        transition does not have a reciprocal, then no reciprocal
5354        properties change. However, if the second transition does not
5355        have a reciprocal and the first does, the first transition's
5356        reciprocal will be set to the reciprocal of the second
5357        transition, and that transition will not be deleted as usual.
5358
5359        ## Example
5360
5361        >>> g = DecisionGraph()
5362        >>> g.addDecision('A')
5363        0
5364        >>> g.addDecision('B')
5365        1
5366        >>> g.addTransition('A', 'up', 'B')
5367        >>> g.addTransition('B', 'down', 'A')
5368        >>> g.setReciprocal('A', 'up', 'down')
5369        >>> # Merging a transition with no reciprocal
5370        >>> g.addTransition('A', 'up2', 'B')
5371        >>> g.mergeTransitions('A', 'up2', 'up')
5372        >>> g.getDestination('A', 'up2') is None
5373        True
5374        >>> g.getDestination('A', 'up')
5375        1
5376        >>> # Merging a transition with a reciprocal & tags
5377        >>> g.addTransition('A', 'up2', 'B')
5378        >>> g.addTransition('B', 'down2', 'A')
5379        >>> g.setReciprocal('A', 'up2', 'down2')
5380        >>> g.tagTransition('A', 'up2', 'one')
5381        >>> g.tagTransition('B', 'down2', 'two')
5382        >>> g.mergeTransitions('B', 'down2', 'down')
5383        >>> g.getDestination('A', 'up2') is None
5384        True
5385        >>> g.getDestination('A', 'up')
5386        1
5387        >>> g.getDestination('B', 'down2') is None
5388        True
5389        >>> g.getDestination('B', 'down')
5390        0
5391        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
5392        >>> g.addTransition('A', 'up2', 'B')
5393        >>> g.setTransitionProperties(
5394        ...     'A',
5395        ...     'up2',
5396        ...     requirement=base.ReqCapability('dash')
5397        ... )
5398        >>> g.setTransitionProperties('A', 'up',
5399        ...     requirement=base.ReqCapability('slide'))
5400        >>> g.mergeTransitions('A', 'up2', 'up')
5401        >>> g.getDestination('A', 'up2') is None
5402        True
5403        >>> repr(g.getTransitionRequirement('A', 'up'))
5404        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
5405        >>> # Errors if destinations differ, or if something is missing
5406        >>> g.mergeTransitions('A', 'down', 'up')
5407        Traceback (most recent call last):
5408        ...
5409        exploration.core.MissingTransitionError...
5410        >>> g.mergeTransitions('Z', 'one', 'two')
5411        Traceback (most recent call last):
5412        ...
5413        exploration.core.MissingDecisionError...
5414        >>> g.addDecision('C')
5415        2
5416        >>> g.addTransition('A', 'down', 'C')
5417        >>> g.mergeTransitions('A', 'down', 'up')
5418        Traceback (most recent call last):
5419        ...
5420        exploration.core.InvalidDestinationError...
5421        >>> # Merging a reciprocal onto an edge that doesn't have one
5422        >>> g.addTransition('A', 'down2', 'C')
5423        >>> g.addTransition('C', 'up2', 'A')
5424        >>> g.setReciprocal('A', 'down2', 'up2')
5425        >>> g.tagTransition('C', 'up2', 'narrow')
5426        >>> g.getReciprocal('A', 'down') is None
5427        True
5428        >>> g.mergeTransitions('A', 'down2', 'down')
5429        >>> g.getDestination('A', 'down2') is None
5430        True
5431        >>> g.getDestination('A', 'down')
5432        2
5433        >>> g.getDestination('C', 'up2')
5434        0
5435        >>> g.getReciprocal('A', 'down')
5436        'up2'
5437        >>> g.getReciprocal('C', 'up2')
5438        'down'
5439        >>> g.transitionTags('C', 'up2')
5440        {'narrow': 1}
5441        >>> # Merging without a reciprocal
5442        >>> g.addTransition('C', 'up', 'A')
5443        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
5444        >>> g.getDestination('C', 'up2') is None
5445        True
5446        >>> g.getDestination('C', 'up')
5447        0
5448        >>> g.transitionTags('C', 'up') # tag gets merged
5449        {'narrow': 1}
5450        >>> g.getDestination('A', 'down')
5451        2
5452        >>> g.getReciprocal('A', 'down') is None
5453        True
5454        >>> g.getReciprocal('C', 'up') is None
5455        True
5456        >>> # Merging w/ normal reciprocals
5457        >>> g.addDecision('D')
5458        3
5459        >>> g.addDecision('E')
5460        4
5461        >>> g.addTransition('D', 'up', 'E', 'return')
5462        >>> g.addTransition('E', 'down', 'D')
5463        >>> g.mergeTransitions('E', 'return', 'down')
5464        >>> g.getDestination('D', 'up')
5465        4
5466        >>> g.getDestination('E', 'down')
5467        3
5468        >>> g.getDestination('E', 'return') is None
5469        True
5470        >>> g.getReciprocal('D', 'up')
5471        'down'
5472        >>> g.getReciprocal('E', 'down')
5473        'up'
5474        >>> # Merging w/ weird reciprocals
5475        >>> g.addTransition('E', 'return', 'D')
5476        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
5477        >>> g.getReciprocal('D', 'up')
5478        'down'
5479        >>> g.getReciprocal('E', 'down')
5480        'up'
5481        >>> g.getReciprocal('E', 'return') # shared
5482        'up'
5483        >>> g.mergeTransitions('E', 'return', 'down')
5484        >>> g.getDestination('D', 'up')
5485        4
5486        >>> g.getDestination('E', 'down')
5487        3
5488        >>> g.getDestination('E', 'return') is None
5489        True
5490        >>> g.getReciprocal('D', 'up')
5491        'down'
5492        >>> g.getReciprocal('E', 'down')
5493        'up'
5494        """
5495        fromID = self.resolveDecision(fromDecision)
5496
5497        # Short-circuit in the no-op case
5498        if merge == mergeInto:
5499            return
5500
5501        # These lines will raise a MissingDecisionError or
5502        # MissingTransitionError if needed
5503        dest1 = self.destination(fromID, merge)
5504        dest2 = self.destination(fromID, mergeInto)
5505
5506        if dest1 != dest2:
5507            raise InvalidDestinationError(
5508                f"Cannot merge transition {merge!r} into transition"
5509                f" {mergeInto!r} from decision"
5510                f" {self.identityOf(fromDecision)} because their"
5511                f" destinations are different ({self.identityOf(dest1)}"
5512                f" and {self.identityOf(dest2)}).\nNote: you can use"
5513                f" `retargetTransition` to change the destination of a"
5514                f" transition."
5515            )
5516
5517        # Find and the transition properties
5518        props1 = self.getTransitionProperties(fromID, merge)
5519        props2 = self.getTransitionProperties(fromID, mergeInto)
5520        merged = mergeProperties(props1, props2)
5521        # Note that this doesn't change the reciprocal:
5522        self.setTransitionProperties(fromID, mergeInto, **merged)
5523
5524        # Merge the reciprocal properties if requested
5525        # Get reciprocal to merge into
5526        reciprocal = self.getReciprocal(fromID, mergeInto)
5527        # Get reciprocal that needs cleaning up
5528        altReciprocal = self.getReciprocal(fromID, merge)
5529        # If the reciprocal to be merged actually already was the
5530        # reciprocal to merge into, there's nothing to do here
5531        if altReciprocal != reciprocal:
5532            if not mergeReciprocal:
5533                # In this case, we sever the reciprocal relationship if
5534                # there is a reciprocal
5535                if altReciprocal is not None:
5536                    self.setReciprocal(dest1, altReciprocal, None)
5537                    # By default setBoth takes care of the other half
5538            else:
5539                # In this case, we try to merge reciprocals
5540                # If altReciprocal is None, we don't need to do anything
5541                if altReciprocal is not None:
5542                    # Was there already a reciprocal or not?
5543                    if reciprocal is None:
5544                        # altReciprocal becomes the new reciprocal and is
5545                        # not deleted
5546                        self.setReciprocal(
5547                            fromID,
5548                            mergeInto,
5549                            altReciprocal
5550                        )
5551                    else:
5552                        # merge reciprocal properties
5553                        props1 = self.getTransitionProperties(
5554                            dest1,
5555                            altReciprocal
5556                        )
5557                        props2 = self.getTransitionProperties(
5558                            dest2,
5559                            reciprocal
5560                        )
5561                        merged = mergeProperties(props1, props2)
5562                        self.setTransitionProperties(
5563                            dest1,
5564                            reciprocal,
5565                            **merged
5566                        )
5567
5568                        # delete the old reciprocal transition
5569                        self.remove_edge(dest1, fromID, altReciprocal)
5570
5571        # Delete the old transition (reciprocal deletion/severance is
5572        # handled above if necessary)
5573        self.remove_edge(fromID, dest1, merge)
5574
5575    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
5576        """
5577        Returns `True` or `False` depending on whether or not the
5578        specified decision has been confirmed. Uses the presence or
5579        absence of the 'unconfirmed' tag to determine this.
5580
5581        Note: 'unconfirmed' is used instead of 'confirmed' so that large
5582        graphs with many confirmed nodes will be smaller when saved.
5583        """
5584        dID = self.resolveDecision(decision)
5585
5586        return 'unconfirmed' not in self.nodes[dID]['tags']
5587
5588    def replaceUnconfirmed(
5589        self,
5590        fromDecision: base.AnyDecisionSpecifier,
5591        transition: base.Transition,
5592        connectTo: Optional[base.AnyDecisionSpecifier] = None,
5593        reciprocal: Optional[base.Transition] = None,
5594        requirement: Optional[base.Requirement] = None,
5595        applyConsequence: Optional[base.Consequence] = None,
5596        placeInZone: Union[type[base.DefaultZone], base.Zone, None] = None,
5597        forceNew: bool = False,
5598        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
5599        annotations: Optional[List[base.Annotation]] = None,
5600        revRequires: Optional[base.Requirement] = None,
5601        revConsequence: Optional[base.Consequence] = None,
5602        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5603        revAnnotations: Optional[List[base.Annotation]] = None,
5604        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5605        decisionAnnotations: Optional[List[base.Annotation]] = None
5606    ) -> Tuple[
5607        Dict[base.Transition, base.Transition],
5608        Dict[base.Transition, base.Transition]
5609    ]:
5610        """
5611        Given a decision and an edge name in that decision, where the
5612        named edge leads to a decision with an unconfirmed exploration
5613        state (see `isConfirmed`), renames the unexplored decision on
5614        the other end of that edge using the given `connectTo` name, or
5615        if a decision using that name already exists, merges the
5616        unexplored decision into that decision. If `connectTo` is a
5617        `DecisionSpecifier` whose target doesn't exist, it will be
5618        treated as just a name, but if it's an ID and it doesn't exist,
5619        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
5620        a reciprocal edge will be added using that name connecting the
5621        `connectTo` decision back to the original decision. If this
5622        transition already exists, it must also point to a node which is
5623        also unexplored, and which will also be merged into the
5624        `fromDecision` node.
5625
5626        If `connectTo` is not given (or is set to `None` explicitly)
5627        then the name of the unexplored decision will not be changed,
5628        unless that name has the form `'_u.-n-'` where `-n-` is a positive
5629        integer (i.e., the form given to automatically-named unknown
5630        nodes). In that case, the name will be changed to `'_x.-n-'` using
5631        the same number, or a higher number if that name is already taken.
5632
5633        If the destination is being renamed or if the destination's
5634        exploration state counts as unexplored, the exploration state of
5635        the destination will be set to 'exploring'.
5636
5637        If a `placeInZone` is specified, the destination will be placed
5638        directly into that zone (even if it already existed and has zone
5639        information), and it will be removed from any other zones it had
5640        been a direct member of. If `placeInZone` is set to
5641        `DefaultZone`, then the destination will be placed into each zone
5642        which is a direct parent of the origin, but only if the
5643        destination is not an already-explored existing decision AND
5644        it is not already in any zones (in those cases no zone changes
5645        are made). This will also remove it from any previous zones it
5646        had been a part of. If `placeInZone` is left as `None` (the
5647        default) no zone changes are made.
5648
5649        If `placeInZone` is specified and that zone didn't already exist,
5650        it will be created as a new level-0 zone and will be added as a
5651        sub-zone of each zone that's a direct parent of any level-0 zone
5652        that the origin is a member of.
5653
5654        If `forceNew` is specified, then the destination will just be
5655        renamed, even if another decision with the same name already
5656        exists. It's an error to use `forceNew` with a decision ID as
5657        the destination.
5658
5659        Any additional edges pointing to or from the unknown node(s)
5660        being replaced will also be re-targeted at the now-discovered
5661        known destination(s) if necessary. These edges will retain their
5662        reciprocal names, or if this would cause a name clash, they will
5663        be renamed with a suffix (see `retargetTransition`).
5664
5665        The return value is a pair of dictionaries mapping old names to
5666        new ones that just includes the names which were changed. The
5667        first dictionary contains renamed transitions that are outgoing
5668        from the new destination node (which used to be outgoing from
5669        the unexplored node). The second dictionary contains renamed
5670        transitions that are outgoing from the source node (which used
5671        to be outgoing from the unexplored node attached to the
5672        reciprocal transition; if there was no reciprocal transition
5673        specified then this will always be an empty dictionary).
5674
5675        An `ExplorationStatusError` will be raised if the destination
5676        of the specified transition counts as visited (see
5677        `hasBeenVisited`). An `ExplorationStatusError` will also be
5678        raised if the `connectTo`'s `reciprocal` transition does not lead
5679        to an unconfirmed decision (it's okay if this second transition
5680        doesn't exist). A `TransitionCollisionError` will be raised if
5681        the unconfirmed destination decision already has an outgoing
5682        transition with the specified `reciprocal` which does not lead
5683        back to the `fromDecision`.
5684
5685        The transition properties (requirement, consequences, tags,
5686        and/or annotations) of the replaced transition will be copied
5687        over to the new transition. Transition properties from the
5688        reciprocal transition will also be copied for the newly created
5689        reciprocal edge. Properties for any additional edges to/from the
5690        unknown node will also be copied.
5691
5692        Also, any transition properties on existing forward or reciprocal
5693        edges from the destination node with the indicated reverse name
5694        will be merged with those from the target transition. Note that
5695        this merging process may introduce corruption of complex
5696        transition consequences. TODO: Fix that!
5697
5698        Any tags and annotations are added to copied tags/annotations,
5699        but specified requirements, and/or consequences will replace
5700        previous requirements/consequences, rather than being added to
5701        them.
5702
5703        ## Example
5704
5705        >>> g = DecisionGraph()
5706        >>> g.addDecision('A')
5707        0
5708        >>> g.addUnexploredEdge('A', 'up')
5709        1
5710        >>> g.destination('A', 'up')
5711        1
5712        >>> g.destination('_u.0', 'return')
5713        0
5714        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
5715        ({}, {})
5716        >>> g.destination('A', 'up')
5717        1
5718        >>> g.nameFor(1)
5719        'B'
5720        >>> g.destination('B', 'down')
5721        0
5722        >>> g.getDestination('B', 'return') is None
5723        True
5724        >>> '_u.0' in g.nameLookup
5725        False
5726        >>> g.getReciprocal('A', 'up')
5727        'down'
5728        >>> g.getReciprocal('B', 'down')
5729        'up'
5730        >>> # Two unexplored edges to the same node:
5731        >>> g.addDecision('C')
5732        2
5733        >>> g.addTransition('B', 'next', 'C')
5734        >>> g.addTransition('C', 'prev', 'B')
5735        >>> g.setReciprocal('B', 'next', 'prev')
5736        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
5737        3
5738        >>> g.addTransition('C', 'down', 'D')
5739        >>> g.addTransition('D', 'up', 'C')
5740        >>> g.setReciprocal('C', 'down', 'up')
5741        >>> g.replaceUnconfirmed('C', 'down')
5742        ({}, {})
5743        >>> g.destination('C', 'down')
5744        3
5745        >>> g.destination('A', 'next')
5746        3
5747        >>> g.destinationsFrom('D')
5748        {'prev': 0, 'up': 2}
5749        >>> g.decisionTags('D')
5750        {}
5751        >>> # An unexplored transition which turns out to connect to a
5752        >>> # known decision, with name collisions
5753        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
5754        4
5755        >>> g.tagDecision('_u.2', 'wet')
5756        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
5757        Traceback (most recent call last):
5758        ...
5759        exploration.core.TransitionCollisionError...
5760        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
5761        5
5762        >>> g.tagDecision('_u.3', 'dry')
5763        >>> # Add transitions that will collide when merged
5764        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
5765        6
5766        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
5767        7
5768        >>> g.getReciprocal('A', 'prev')
5769        'next'
5770        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
5771        ({'prev': 'prev.1'}, {'up': 'up.1'})
5772        >>> g.destination('A', 'prev')
5773        3
5774        >>> g.destination('D', 'next')
5775        0
5776        >>> g.getReciprocal('A', 'prev')
5777        'next'
5778        >>> g.getReciprocal('D', 'next')
5779        'prev'
5780        >>> # Note that further unexplored structures are NOT merged
5781        >>> # even if they match against existing structures...
5782        >>> g.destination('A', 'up.1')
5783        6
5784        >>> g.destination('D', 'prev.1')
5785        7
5786        >>> '_u.2' in g.nameLookup
5787        False
5788        >>> '_u.3' in g.nameLookup
5789        False
5790        >>> g.decisionTags('D') # tags are merged
5791        {'dry': 1}
5792        >>> g.decisionTags('A')
5793        {'wet': 1}
5794        >>> # Auto-renaming an anonymous unexplored node
5795        >>> g.addUnexploredEdge('B', 'out')
5796        8
5797        >>> g.replaceUnconfirmed('B', 'out')
5798        ({}, {})
5799        >>> '_u.6' in g
5800        False
5801        >>> g.destination('B', 'out')
5802        8
5803        >>> g.nameFor(8)
5804        '_x.6'
5805        >>> g.destination('_x.6', 'return')
5806        1
5807        >>> # Placing a node into a zone
5808        >>> g.addUnexploredEdge('B', 'through')
5809        9
5810        >>> g.getDecision('E') is None
5811        True
5812        >>> g.replaceUnconfirmed(
5813        ...     'B',
5814        ...     'through',
5815        ...     'E',
5816        ...     'back',
5817        ...     placeInZone='Zone'
5818        ... )
5819        ({}, {})
5820        >>> g.getDecision('E')
5821        9
5822        >>> g.destination('B', 'through')
5823        9
5824        >>> g.destination('E', 'back')
5825        1
5826        >>> g.zoneParents(9)
5827        {'Zone'}
5828        >>> g.addUnexploredEdge('E', 'farther')
5829        10
5830        >>> g.replaceUnconfirmed(
5831        ...     'E',
5832        ...     'farther',
5833        ...     'F',
5834        ...     'closer',
5835        ...     placeInZone=base.DefaultZone
5836        ... )
5837        ({}, {})
5838        >>> g.destination('E', 'farther')
5839        10
5840        >>> g.destination('F', 'closer')
5841        9
5842        >>> g.zoneParents(10)
5843        {'Zone'}
5844        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
5845        11
5846        >>> g.replaceUnconfirmed(
5847        ...     'F',
5848        ...     'backwards',
5849        ...     'G',
5850        ...     'forwards',
5851        ...     placeInZone=base.DefaultZone
5852        ... )
5853        ({}, {})
5854        >>> g.destination('F', 'backwards')
5855        11
5856        >>> g.destination('G', 'forwards')
5857        10
5858        >>> g.zoneParents(11)  # not changed since it already had a zone
5859        {'Enoz'}
5860        >>> # TODO: forceNew example
5861        """
5862
5863        # Defaults
5864        if tags is None:
5865            tags = {}
5866        if annotations is None:
5867            annotations = []
5868        if revTags is None:
5869            revTags = {}
5870        if revAnnotations is None:
5871            revAnnotations = []
5872        if decisionTags is None:
5873            decisionTags = {}
5874        if decisionAnnotations is None:
5875            decisionAnnotations = []
5876
5877        # Resolve source
5878        fromID = self.resolveDecision(fromDecision)
5879
5880        # Figure out destination decision
5881        oldUnexplored = self.destination(fromID, transition)
5882        if self.isConfirmed(oldUnexplored):
5883            raise ExplorationStatusError(
5884                f"Transition {transition!r} from"
5885                f" {self.identityOf(fromDecision)} does not lead to an"
5886                f" unconfirmed decision (it leads to"
5887                f" {self.identityOf(oldUnexplored)} which is not tagged"
5888                f" 'unconfirmed')."
5889            )
5890
5891        # Resolve destination
5892        newName: Optional[base.DecisionName] = None
5893        connectID: Optional[base.DecisionID] = None
5894        if forceNew:
5895            if isinstance(connectTo, base.DecisionID):
5896                raise TypeError(
5897                    f"connectTo cannot be a decision ID when forceNew"
5898                    f" is True. Got: {self.identityOf(connectTo)}"
5899                )
5900            elif isinstance(connectTo, base.DecisionSpecifier):
5901                newName = connectTo.name
5902            elif isinstance(connectTo, base.DecisionName):
5903                newName = connectTo
5904            elif connectTo is None:
5905                oldName = self.nameFor(oldUnexplored)
5906                if (
5907                    oldName.startswith('_u.')
5908                and oldName[3:].isdigit()
5909                ):
5910                    newName = utils.uniqueName('_x.' + oldName[3:], self)
5911                else:
5912                    newName = oldName
5913            else:
5914                raise TypeError(
5915                    f"Invalid connectTo value: {connectTo!r}"
5916                )
5917        elif connectTo is not None:
5918            try:
5919                connectID = self.resolveDecision(connectTo)
5920                # leave newName as None
5921            except MissingDecisionError:
5922                if isinstance(connectTo, int):
5923                    raise
5924                elif isinstance(connectTo, base.DecisionSpecifier):
5925                    newName = connectTo.name
5926                    # The domain & zone are ignored here
5927                else:  # Must just be a string
5928                    assert isinstance(connectTo, str)
5929                    newName = connectTo
5930        else:
5931            # If connectTo name wasn't specified, use current name of
5932            # unknown node unless it's a default name
5933            oldName = self.nameFor(oldUnexplored)
5934            if (
5935                oldName.startswith('_u.')
5936            and oldName[3:].isdigit()
5937            ):
5938                newName = utils.uniqueName('_x.' + oldName[3:], self)
5939            else:
5940                newName = oldName
5941
5942        # One or the other should be valid at this point
5943        assert connectID is not None or newName is not None
5944
5945        # Check that the old unknown doesn't have a reciprocal edge that
5946        # would collide with the specified return edge
5947        if reciprocal is not None:
5948            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
5949            if revFromUnknown not in (None, fromID):
5950                raise TransitionCollisionError(
5951                    f"Transition {reciprocal!r} from"
5952                    f" {self.identityOf(oldUnexplored)} exists and does"
5953                    f" not lead back to {self.identityOf(fromDecision)}"
5954                    f" (it leads to {self.identityOf(revFromUnknown)})."
5955                )
5956
5957        # Remember old reciprocal edge for future merging in case
5958        # it's not reciprocal
5959        oldReciprocal = self.getReciprocal(fromID, transition)
5960
5961        # Apply any new tags or annotations, or create a new node
5962        needsZoneInfo = False
5963        if connectID is not None:
5964            # Before applying tags, check if we need to error out
5965            # because of a reciprocal edge that points to a known
5966            # destination:
5967            if reciprocal is not None:
5968                otherOldUnknown: Optional[
5969                    base.DecisionID
5970                ] = self.getDestination(
5971                    connectID,
5972                    reciprocal
5973                )
5974                if (
5975                    otherOldUnknown is not None
5976                and self.isConfirmed(otherOldUnknown)
5977                ):
5978                    raise ExplorationStatusError(
5979                        f"Reciprocal transition {reciprocal!r} from"
5980                        f" {self.identityOf(connectTo)} does not lead"
5981                        f" to an unconfirmed decision (it leads to"
5982                        f" {self.identityOf(otherOldUnknown)})."
5983                    )
5984            self.tagDecision(connectID, decisionTags)
5985            self.annotateDecision(connectID, decisionAnnotations)
5986            # Still needs zone info if the place we're connecting to was
5987            # unconfirmed up until now, since unconfirmed nodes don't
5988            # normally get zone info when they're created.
5989            if not self.isConfirmed(connectID):
5990                needsZoneInfo = True
5991
5992            # First, merge the old unknown with the connectTo node...
5993            destRenames = self.mergeDecisions(
5994                oldUnexplored,
5995                connectID,
5996                errorOnNameColision=False
5997            )
5998        else:
5999            needsZoneInfo = True
6000            if len(self.zoneParents(oldUnexplored)) > 0:
6001                needsZoneInfo = False
6002            assert newName is not None
6003            self.renameDecision(oldUnexplored, newName)
6004            connectID = oldUnexplored
6005            # In this case there can't be an other old unknown
6006            otherOldUnknown = None
6007            destRenames = {}  # empty
6008
6009        # Check for domain mismatch to stifle zone updates:
6010        fromDomain = self.domainFor(fromID)
6011        if connectID is None:
6012            destDomain = self.domainFor(oldUnexplored)
6013        else:
6014            destDomain = self.domainFor(connectID)
6015
6016        # Stifle zone updates if there's a mismatch
6017        if fromDomain != destDomain:
6018            needsZoneInfo = False
6019
6020        # Records renames that happen at the source (from node)
6021        sourceRenames = {}  # empty for now
6022
6023        assert connectID is not None
6024
6025        # Apply the new zone if there is one
6026        if placeInZone is not None:
6027            if placeInZone is base.DefaultZone:
6028                # When using DefaultZone, changes are only made for new
6029                # destinations which don't already have any zones and
6030                # which are in the same domain as the departing node:
6031                # they get placed into each zone parent of the source
6032                # decision.
6033                if needsZoneInfo:
6034                    # Remove destination from all current parents
6035                    removeFrom = set(self.zoneParents(connectID))  # copy
6036                    for parent in removeFrom:
6037                        self.removeDecisionFromZone(connectID, parent)
6038                    # Add it to parents of origin
6039                    for parent in self.zoneParents(fromID):
6040                        self.addDecisionToZone(connectID, parent)
6041            else:
6042                placeInZone = cast(base.Zone, placeInZone)
6043                # Create the zone if it doesn't already exist
6044                if self.getZoneInfo(placeInZone) is None:
6045                    self.createZone(placeInZone, 0)
6046                    # Add it to each grandparent of the from decision
6047                    for parent in self.zoneParents(fromID):
6048                        for grandparent in self.zoneParents(parent):
6049                            self.addZoneToZone(placeInZone, grandparent)
6050                # Remove destination from all current parents
6051                for parent in set(self.zoneParents(connectID)):
6052                    self.removeDecisionFromZone(connectID, parent)
6053                # Add it to the specified zone
6054                self.addDecisionToZone(connectID, placeInZone)
6055
6056        # Next, if there is a reciprocal name specified, we do more...
6057        if reciprocal is not None:
6058            # Figure out what kind of merging needs to happen
6059            if otherOldUnknown is None:
6060                if revFromUnknown is None:
6061                    # Just create the desired reciprocal transition, which
6062                    # we know does not already exist
6063                    self.addTransition(connectID, reciprocal, fromID)
6064                    otherOldReciprocal = None
6065                else:
6066                    # Reciprocal exists, as revFromUnknown
6067                    otherOldReciprocal = None
6068            else:
6069                otherOldReciprocal = self.getReciprocal(
6070                    connectID,
6071                    reciprocal
6072                )
6073                # we need to merge otherOldUnknown into our fromDecision
6074                sourceRenames = self.mergeDecisions(
6075                    otherOldUnknown,
6076                    fromID,
6077                    errorOnNameColision=False
6078                )
6079                # Unvisited tag after merge only if both were
6080
6081            # No matter what happened we ensure the reciprocal
6082            # relationship is set up:
6083            self.setReciprocal(fromID, transition, reciprocal)
6084
6085            # Now we might need to merge some transitions:
6086            # - Any reciprocal of the target transition should be merged
6087            #   with reciprocal (if it was already reciprocal, that's a
6088            #   no-op).
6089            # - Any reciprocal of the reciprocal transition from the target
6090            #   node (leading to otherOldUnknown) should be merged with
6091            #   the target transition, even if it shared a name and was
6092            #   renamed as a result.
6093            # - If reciprocal was renamed during the initial merge, those
6094            #   transitions should be merged.
6095
6096            # Merge old reciprocal into reciprocal
6097            if oldReciprocal is not None:
6098                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
6099                if self.getDestination(connectID, oldRev) is not None:
6100                    # Note that we don't want to auto-merge the reciprocal,
6101                    # which is the target transition
6102                    self.mergeTransitions(
6103                        connectID,
6104                        oldRev,
6105                        reciprocal,
6106                        mergeReciprocal=False
6107                    )
6108                    # Remove it from the renames map
6109                    if oldReciprocal in destRenames:
6110                        del destRenames[oldReciprocal]
6111
6112            # Merge reciprocal reciprocal from otherOldUnknown
6113            if otherOldReciprocal is not None:
6114                otherOldRev = sourceRenames.get(
6115                    otherOldReciprocal,
6116                    otherOldReciprocal
6117                )
6118                # Note that the reciprocal is reciprocal, which we don't
6119                # need to merge
6120                self.mergeTransitions(
6121                    fromID,
6122                    otherOldRev,
6123                    transition,
6124                    mergeReciprocal=False
6125                )
6126                # Remove it from the renames map
6127                if otherOldReciprocal in sourceRenames:
6128                    del sourceRenames[otherOldReciprocal]
6129
6130            # Merge any renamed reciprocal onto reciprocal
6131            if reciprocal in destRenames:
6132                extraRev = destRenames[reciprocal]
6133                self.mergeTransitions(
6134                    connectID,
6135                    extraRev,
6136                    reciprocal,
6137                    mergeReciprocal=False
6138                )
6139                # Remove it from the renames map
6140                del destRenames[reciprocal]
6141
6142        # Accumulate new tags & annotations for the transitions
6143        self.tagTransition(fromID, transition, tags)
6144        self.annotateTransition(fromID, transition, annotations)
6145
6146        if reciprocal is not None:
6147            self.tagTransition(connectID, reciprocal, revTags)
6148            self.annotateTransition(connectID, reciprocal, revAnnotations)
6149
6150        # Override copied requirement/consequences for the transitions
6151        if requirement is not None:
6152            self.setTransitionRequirement(
6153                fromID,
6154                transition,
6155                requirement
6156            )
6157        if applyConsequence is not None:
6158            self.setConsequence(
6159                fromID,
6160                transition,
6161                applyConsequence
6162            )
6163
6164        if reciprocal is not None:
6165            if revRequires is not None:
6166                self.setTransitionRequirement(
6167                    connectID,
6168                    reciprocal,
6169                    revRequires
6170                )
6171            if revConsequence is not None:
6172                self.setConsequence(
6173                    connectID,
6174                    reciprocal,
6175                    revConsequence
6176                )
6177
6178        # Remove 'unconfirmed' tag if it was present
6179        self.untagDecision(connectID, 'unconfirmed')
6180
6181        # Final checks
6182        assert self.getDestination(fromDecision, transition) == connectID
6183        useConnect: base.AnyDecisionSpecifier
6184        useRev: Optional[str]
6185        if connectTo is None:
6186            useConnect = connectID
6187        else:
6188            useConnect = connectTo
6189        if reciprocal is None:
6190            useRev = self.getReciprocal(fromDecision, transition)
6191        else:
6192            useRev = reciprocal
6193        if useRev is not None:
6194            try:
6195                assert self.getDestination(useConnect, useRev) == fromID
6196            except AmbiguousDecisionSpecifierError:
6197                assert self.getDestination(connectID, useRev) == fromID
6198
6199        # Return our final rename dictionaries
6200        return (destRenames, sourceRenames)
6201
6202    def endingID(self, name: base.DecisionName) -> base.DecisionID:
6203        """
6204        Returns the decision ID for the ending with the specified name.
6205        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
6206        don't normally include any zone information. If no ending with
6207        the specified name already existed, then a new ending with that
6208        name will be created and its Decision ID will be returned.
6209
6210        If a new decision is created, it will be tagged as unconfirmed.
6211
6212        Note that endings mostly aren't special: they're normal
6213        decisions in a separate singular-focalized domain. However, some
6214        parts of the exploration and journal machinery treat them
6215        differently (in particular, taking certain actions via
6216        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
6217        active is an error.
6218        """
6219        # Create our new ending decision if we need to
6220        try:
6221            endID = self.resolveDecision(
6222                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
6223            )
6224        except MissingDecisionError:
6225            # Create a new decision for the ending
6226            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
6227            # Tag it as unconfirmed
6228            self.tagDecision(endID, 'unconfirmed')
6229
6230        return endID
6231
6232    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
6233        """
6234        Given the name of a trigger group, returns the ID of the special
6235        node representing that trigger group in the `TRIGGERS_DOMAIN`.
6236        If the specified group didn't already exist, it will be created.
6237
6238        Trigger group decisions are not special: they just exist in a
6239        separate spreading-focalized domain and have a few API methods to
6240        access them, but all the normal decision-related API methods
6241        still work. Their intended use is for sets of global triggers,
6242        by attaching actions with the 'trigger' tag to them and then
6243        activating or deactivating them as needed.
6244        """
6245        result = self.getDecision(
6246            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
6247        )
6248        if result is None:
6249            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
6250        else:
6251            return result
6252
6253    @staticmethod
6254    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
6255        """
6256        Returns one of a number of example decision graphs, depending on
6257        the string given. It returns a fresh copy each time. The graphs
6258        are:
6259
6260        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
6261            and 2, each connected to the next in the sequence by a
6262            'next' transition with reciprocal 'prev'. In other words, a
6263            simple little triangle. There are no tags, annotations,
6264            requirements, consequences, mechanisms, or equivalences.
6265        - 'abc': A more complicated 3-node setup that introduces a
6266            little bit of everything. In this graph, we have the same
6267            three nodes, but different transitions:
6268
6269                * From A you can go 'left' to B with reciprocal 'right'.
6270                * From A you can also go 'up_left' to B with reciprocal
6271                    'up_right'. These transitions both require the
6272                    'grate' mechanism (which is at decision A) to be in
6273                    state 'open'.
6274                * From A you can go 'down' to C with reciprocal 'up'.
6275
6276            (In this graph, B and C are not directly connected to each
6277            other.)
6278
6279            The graph has two level-0 zones 'zoneA' and 'zoneB', along
6280            with a level-1 zone 'upZone'. Decisions A and C are in
6281            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
6282            not.
6283
6284            The decision A has annotation:
6285
6286                'This is a multi-word "annotation."'
6287
6288            The transition 'down' from A has annotation:
6289
6290                "Transition 'annotation.'"
6291
6292            Decision B has tags 'b' with value 1 and 'tag2' with value
6293            '"value"'.
6294
6295            Decision C has tag 'aw"ful' with value "ha'ha'".
6296
6297            Transition 'up' from C has tag 'fast' with value 1.
6298
6299            At decision C there are actions 'grab_helmet' and
6300            'pull_lever'.
6301
6302            The 'grab_helmet' transition requires that you don't have
6303            the 'helmet' capability, and gives you that capability,
6304            deactivating with delay 3.
6305
6306            The 'pull_lever' transition requires that you do have the
6307            'helmet' capability, and takes away that capability, but it
6308            also gives you 1 token, and if you have 2 tokens (before
6309            getting the one extra), it sets the 'grate' mechanism (which
6310            is a decision A) to state 'open' and deactivates.
6311
6312            The graph has an equivalence: having the 'helmet' capability
6313            satisfies requirements for the 'grate' mechanism to be in the
6314            'open' state.
6315
6316        """
6317        result = DecisionGraph()
6318        if which == 'simple':
6319            result.addDecision('A')  # id 0
6320            result.addDecision('B')  # id 1
6321            result.addDecision('C')  # id 2
6322            result.addTransition('A', 'next', 'B', 'prev')
6323            result.addTransition('B', 'next', 'C', 'prev')
6324            result.addTransition('C', 'next', 'A', 'prev')
6325        elif which == 'abc':
6326            result.addDecision('A')  # id 0
6327            result.addDecision('B')  # id 1
6328            result.addDecision('C')  # id 2
6329            result.createZone('zoneA', 0)
6330            result.createZone('zoneB', 0)
6331            result.createZone('upZone', 1)
6332            result.addZoneToZone('zoneA', 'upZone')
6333            result.addDecisionToZone('A', 'zoneA')
6334            result.addDecisionToZone('B', 'zoneB')
6335            result.addDecisionToZone('C', 'zoneA')
6336            result.addTransition('A', 'left', 'B', 'right')
6337            result.addTransition('A', 'up_left', 'B', 'up_right')
6338            result.addTransition('A', 'down', 'C', 'up')
6339            result.setTransitionRequirement(
6340                'A',
6341                'up_left',
6342                base.ReqMechanism('grate', 'open')
6343            )
6344            result.setTransitionRequirement(
6345                'B',
6346                'up_right',
6347                base.ReqMechanism('grate', 'open')
6348            )
6349            result.annotateDecision('A', 'This is a multi-word "annotation."')
6350            result.annotateTransition('A', 'down', "Transition 'annotation.'")
6351            result.tagDecision('B', 'b')
6352            result.tagDecision('B', 'tag2', '"value"')
6353            result.tagDecision('C', 'aw"ful', "ha'ha")
6354            result.tagTransition('C', 'up', 'fast')
6355            result.addMechanism('grate', 'A')
6356            result.addAction(
6357                'C',
6358                'grab_helmet',
6359                base.ReqNot(base.ReqCapability('helmet')),
6360                [
6361                    base.effect(gain='helmet'),
6362                    base.effect(deactivate=True, delay=3)
6363                ]
6364            )
6365            result.addAction(
6366                'C',
6367                'pull_lever',
6368                base.ReqCapability('helmet'),
6369                [
6370                    base.effect(lose='helmet'),
6371                    base.effect(gain=('token', 1)),
6372                    base.condition(
6373                        base.ReqTokens('token', 2),
6374                        [
6375                            base.effect(set=('grate', 'open')),
6376                            base.effect(deactivate=True)
6377                        ]
6378                    )
6379                ]
6380            )
6381            result.addEquivalence(
6382                base.ReqCapability('helmet'),
6383                (0, 'open')
6384            )
6385        else:
6386            raise ValueError(f"Invalid example name: {which!r}")
6387
6388        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 identityOf( self, decision: Union[int, exploration.base.DecisionSpecifier, str, NoneType], includeZones: bool = True, alwaysDomain: Optional[bool] = None) -> str:
937    def identityOf(
938        self,
939        decision: Optional[base.AnyDecisionSpecifier],
940        includeZones: bool = True,
941        alwaysDomain: Optional[bool] = None
942    ) -> str:
943        """
944        Returns a string containing the given decision ID and the name
945        for that decision in parentheses afterwards. If the value
946        provided 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"{dID} ({dSpec}{zSpec}{self.nameFor(dID)})"

Returns a string containing the given decision ID and the name for that decision in parentheses afterwards. 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 namesListing( self, decisions: Collection[int], includeZones: bool = True, indent: int = 2) -> str:
 985    def namesListing(
 986        self,
 987        decisions: Collection[base.DecisionID],
 988        includeZones: bool = True,
 989        indent: int = 2
 990    ) -> str:
 991        """
 992        Returns a multi-line string containing an indented listing of
 993        the provided decision IDs with their names in parentheses after
 994        each. Useful for debugging & error messages.
 995
 996        Includes level-0 zones where applicable, with a zone separator
 997        before the decision, unless `includeZones` is set to False. Where
 998        there are multiple level-0 zones, they're listed together in
 999        brackets.
1000
1001        Uses the string '(none)' when there are no decisions are in the
1002        list.
1003
1004        Set `indent` to something other than 2 to control how much
1005        indentation is added.
1006
1007        For example:
1008
1009        >>> g = DecisionGraph()
1010        >>> g.addDecision('A')
1011        0
1012        >>> g.addDecision('B')
1013        1
1014        >>> g.addDecision('C')
1015        2
1016        >>> g.namesListing(['A', 'C', 'B'])
1017        '  0 (A)\\n  2 (C)\\n  1 (B)\\n'
1018        >>> g.namesListing([])
1019        '  (none)\\n'
1020        >>> g.createZone('zone', 0)
1021        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1022 annotations=[])
1023        >>> g.createZone('zone2', 0)
1024        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1025 annotations=[])
1026        >>> g.createZone('zoneUp', 1)
1027        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
1028 annotations=[])
1029        >>> g.addDecisionToZone(0, 'zone')
1030        >>> g.addDecisionToZone(1, 'zone')
1031        >>> g.addDecisionToZone(1, 'zone2')
1032        >>> g.addDecisionToZone(2, 'zoneUp')  # won't be listed: it's level-1
1033        >>> g.namesListing(['A', 'C', 'B'])
1034        '  0 (zone::A)\\n  2 (C)\\n  1 ([zone, zone2]::B)\\n'
1035        """
1036        ind = ' ' * indent
1037        if len(decisions) == 0:
1038            return ind + '(none)\n'
1039        else:
1040            result = ''
1041            for dID in decisions:
1042                result += ind + self.identityOf(dID, includeZones) + '\n'
1043            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:
1045    def destinationsListing(
1046        self,
1047        destinations: Dict[base.Transition, base.DecisionID],
1048        includeZones: bool = True,
1049        indent: int = 2
1050    ) -> str:
1051        """
1052        Returns a multi-line string containing an indented listing of
1053        the provided transitions along with their destinations and the
1054        names of those destinations in parentheses. Useful for debugging
1055        & error messages. (Use e.g., `destinationsFrom` to get a
1056        transitions -> destinations dictionary in the required format.)
1057
1058        Uses the string '(no transitions)' when there are no transitions
1059        in the dictionary.
1060
1061        Set `indent` to something other than 2 to control how much
1062        indentation is added.
1063
1064        For example:
1065
1066        >>> g = DecisionGraph()
1067        >>> g.addDecision('A')
1068        0
1069        >>> g.addDecision('B')
1070        1
1071        >>> g.addDecision('C')
1072        2
1073        >>> g.addTransition('A', 'north', 'B', 'south')
1074        >>> g.addTransition('B', 'east', 'C', 'west')
1075        >>> g.addTransition('C', 'southwest', 'A', 'northeast')
1076        >>> g.destinationsListing(g.destinationsFrom('A'))
1077        '  north to 1 (B)\\n  northeast to 2 (C)\\n'
1078        >>> g.destinationsListing(g.destinationsFrom('B'))
1079        '  south to 0 (A)\\n  east to 2 (C)\\n'
1080        >>> g.destinationsListing({})
1081        '  (none)\\n'
1082        >>> g.createZone('zone', 0)
1083        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1084 annotations=[])
1085        >>> g.addDecisionToZone(0, 'zone')
1086        >>> g.destinationsListing(g.destinationsFrom('B'))
1087        '  south to 0 (zone::A)\\n  east to 2 (C)\\n'
1088        """
1089        ind = ' ' * indent
1090        if len(destinations) == 0:
1091            return ind + '(none)\n'
1092        else:
1093            result = ''
1094            for transition, dID in destinations.items():
1095                line = f"{transition} to {self.identityOf(dID, includeZones)}"
1096                result += ind + line + '\n'
1097            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:
1099    def domainFor(self, decision: base.AnyDecisionSpecifier) -> base.Domain:
1100        """
1101        Returns the domain that a decision belongs to.
1102        """
1103        dID = self.resolveDecision(decision)
1104        return self.nodes[dID]['domain']

Returns the domain that a decision belongs to.

def allDecisionsInDomain(self, domain: str) -> Set[int]:
1106    def allDecisionsInDomain(
1107        self,
1108        domain: base.Domain
1109    ) -> Set[base.DecisionID]:
1110        """
1111        Returns the set of all `DecisionID`s for decisions in the
1112        specified domain.
1113        """
1114        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:
1116    def destination(
1117        self,
1118        decision: base.AnyDecisionSpecifier,
1119        transition: base.Transition
1120    ) -> base.DecisionID:
1121        """
1122        Overrides base `UniqueExitsGraph.destination` to raise
1123        `MissingDecisionError` or `MissingTransitionError` as
1124        appropriate, and to work with an `AnyDecisionSpecifier`.
1125        """
1126        dID = self.resolveDecision(decision)
1127        try:
1128            return super().destination(dID, transition)
1129        except KeyError:
1130            raise MissingTransitionError(
1131                f"Transition {transition!r} does not exist at decision"
1132                f" {self.identityOf(dID)}."
1133            )

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]:
1135    def getDestination(
1136        self,
1137        decision: base.AnyDecisionSpecifier,
1138        transition: base.Transition,
1139        default: Any = None
1140    ) -> Optional[base.DecisionID]:
1141        """
1142        Overrides base `UniqueExitsGraph.getDestination` with different
1143        argument names, since those matter for the edit DSL.
1144        """
1145        dID = self.resolveDecision(decision)
1146        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]:
1148    def destinationsFrom(
1149        self,
1150        decision: base.AnyDecisionSpecifier
1151    ) -> Dict[base.Transition, base.DecisionID]:
1152        """
1153        Override that just changes the type of the exception from a
1154        `KeyError` to a `MissingDecisionError` when the source does not
1155        exist.
1156        """
1157        dID = self.resolveDecision(decision)
1158        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]:
1160    def bothEnds(
1161        self,
1162        decision: base.AnyDecisionSpecifier,
1163        transition: base.Transition
1164    ) -> Set[base.DecisionID]:
1165        """
1166        Returns a set containing the `DecisionID`(s) for both the start
1167        and end of the specified transition. Raises a
1168        `MissingDecisionError` or `MissingTransitionError`if the
1169        specified decision and/or transition do not exist.
1170
1171        Note that for actions since the source and destination are the
1172        same, the set will have only one element.
1173        """
1174        dID = self.resolveDecision(decision)
1175        result = {dID}
1176        dest = self.destination(dID, transition)
1177        if dest is not None:
1178            result.add(dest)
1179        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]:
1181    def decisionActions(
1182        self,
1183        decision: base.AnyDecisionSpecifier
1184    ) -> Set[base.Transition]:
1185        """
1186        Retrieves the set of self-edges at a decision. Editing the set
1187        will not affect the graph.
1188
1189        Example:
1190
1191        >>> g = DecisionGraph()
1192        >>> g.addDecision('A')
1193        0
1194        >>> g.addDecision('B')
1195        1
1196        >>> g.addDecision('C')
1197        2
1198        >>> g.addAction('A', 'action1')
1199        >>> g.addAction('A', 'action2')
1200        >>> g.addAction('B', 'action3')
1201        >>> sorted(g.decisionActions('A'))
1202        ['action1', 'action2']
1203        >>> g.decisionActions('B')
1204        {'action3'}
1205        >>> g.decisionActions('C')
1206        set()
1207        """
1208        result = set()
1209        dID = self.resolveDecision(decision)
1210        for transition, dest in self.destinationsFrom(dID).items():
1211            if dest == dID:
1212                result.add(transition)
1213        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:
1215    def getTransitionProperties(
1216        self,
1217        decision: base.AnyDecisionSpecifier,
1218        transition: base.Transition
1219    ) -> TransitionProperties:
1220        """
1221        Returns a dictionary containing transition properties for the
1222        specified transition from the specified decision. The properties
1223        included are:
1224
1225        - 'requirement': The requirement for the transition.
1226        - 'consequence': Any consequence of the transition.
1227        - 'tags': Any tags applied to the transition.
1228        - 'annotations': Any annotations on the transition.
1229
1230        The reciprocal of the transition is not included.
1231
1232        The result is a clone of the stored properties; edits to the
1233        dictionary will NOT modify the graph.
1234        """
1235        dID = self.resolveDecision(decision)
1236        dest = self.destination(dID, transition)
1237
1238        info: TransitionProperties = copy.deepcopy(
1239            self.edges[dID, dest, transition]  # type:ignore
1240        )
1241        return {
1242            'requirement': info.get('requirement', base.ReqNothing()),
1243            'consequence': info.get('consequence', []),
1244            'tags': info.get('tags', {}),
1245            'annotations': info.get('annotations', [])
1246        }

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:
1248    def setTransitionProperties(
1249        self,
1250        decision: base.AnyDecisionSpecifier,
1251        transition: base.Transition,
1252        requirement: Optional[base.Requirement] = None,
1253        consequence: Optional[base.Consequence] = None,
1254        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1255        annotations: Optional[List[base.Annotation]] = None
1256    ) -> None:
1257        """
1258        Sets one or more transition properties all at once. Can be used
1259        to set the requirement, consequence, tags, and/or annotations.
1260        Old values are overwritten, although if `None`s are provided (or
1261        arguments are omitted), corresponding properties are not
1262        updated.
1263
1264        To add tags or annotations to existing tags/annotations instead
1265        of replacing them, use `tagTransition` or `annotateTransition`
1266        instead.
1267        """
1268        dID = self.resolveDecision(decision)
1269        if requirement is not None:
1270            self.setTransitionRequirement(dID, transition, requirement)
1271        if consequence is not None:
1272            self.setConsequence(dID, transition, consequence)
1273        if tags is not None:
1274            dest = self.destination(dID, transition)
1275            # TODO: Submit pull request to update MultiDiGraph stubs in
1276            # types-networkx to include OutMultiEdgeView that accepts
1277            # from/to/key tuples as indices.
1278            info = cast(
1279                TransitionProperties,
1280                self.edges[dID, dest, transition]  # type:ignore
1281            )
1282            info['tags'] = tags
1283        if annotations is not None:
1284            dest = self.destination(dID, transition)
1285            info = cast(
1286                TransitionProperties,
1287                self.edges[dID, dest, transition]  # type:ignore
1288            )
1289            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:
1291    def getTransitionRequirement(
1292        self,
1293        decision: base.AnyDecisionSpecifier,
1294        transition: base.Transition
1295    ) -> base.Requirement:
1296        """
1297        Returns the `Requirement` for accessing a specific transition at
1298        a specific decision. For transitions which don't have
1299        requirements, returns a `ReqNothing` instance.
1300        """
1301        dID = self.resolveDecision(decision)
1302        dest = self.destination(dID, transition)
1303
1304        info = cast(
1305            TransitionProperties,
1306            self.edges[dID, dest, transition]  # type:ignore
1307        )
1308
1309        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:
1311    def setTransitionRequirement(
1312        self,
1313        decision: base.AnyDecisionSpecifier,
1314        transition: base.Transition,
1315        requirement: Optional[base.Requirement]
1316    ) -> None:
1317        """
1318        Sets the `Requirement` for accessing a specific transition at
1319        a specific decision. Raises a `KeyError` if the decision or
1320        transition does not exist.
1321
1322        Deletes the requirement if `None` is given as the requirement.
1323
1324        Use `parsing.ParseFormat.parseRequirement` first if you have a
1325        requirement in string format.
1326
1327        Does not raise an error if deletion is requested for a
1328        non-existent requirement, and silently overwrites any previous
1329        requirement.
1330        """
1331        dID = self.resolveDecision(decision)
1332
1333        dest = self.destination(dID, transition)
1334
1335        info = cast(
1336            TransitionProperties,
1337            self.edges[dID, dest, transition]  # type:ignore
1338        )
1339
1340        if requirement is None:
1341            try:
1342                del info['requirement']
1343            except KeyError:
1344                pass
1345        else:
1346            if not isinstance(requirement, base.Requirement):
1347                raise TypeError(
1348                    f"Invalid requirement type: {type(requirement)}"
1349                )
1350
1351            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]]:
1353    def getConsequence(
1354        self,
1355        decision: base.AnyDecisionSpecifier,
1356        transition: base.Transition
1357    ) -> base.Consequence:
1358        """
1359        Retrieves the consequence of a transition.
1360
1361        A `KeyError` is raised if the specified decision/transition
1362        combination doesn't exist.
1363        """
1364        dID = self.resolveDecision(decision)
1365
1366        dest = self.destination(dID, transition)
1367
1368        info = cast(
1369            TransitionProperties,
1370            self.edges[dID, dest, transition]  # type:ignore
1371        )
1372
1373        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]:
1375    def addConsequence(
1376        self,
1377        decision: base.AnyDecisionSpecifier,
1378        transition: base.Transition,
1379        consequence: base.Consequence
1380    ) -> Tuple[int, int]:
1381        """
1382        Adds the given `Consequence` to the consequence list for the
1383        specified transition, extending that list at the end. Note that
1384        this does NOT make a copy of the consequence, so it should not
1385        be used to copy consequences from one transition to another
1386        without making a deep copy first.
1387
1388        A `MissingDecisionError` or a `MissingTransitionError` is raised
1389        if the specified decision/transition combination doesn't exist.
1390
1391        Returns a pair of integers indicating the minimum and maximum
1392        depth-first-traversal-indices of the added consequence part(s).
1393        The outer consequence list itself (index 0) is not counted.
1394
1395        >>> d = DecisionGraph()
1396        >>> d.addDecision('A')
1397        0
1398        >>> d.addDecision('B')
1399        1
1400        >>> d.addTransition('A', 'fwd', 'B', 'rev')
1401        >>> d.addConsequence('A', 'fwd', [base.effect(gain='sword')])
1402        (1, 1)
1403        >>> d.addConsequence('B', 'rev', [base.effect(lose='sword')])
1404        (1, 1)
1405        >>> ef = d.getConsequence('A', 'fwd')
1406        >>> er = d.getConsequence('B', 'rev')
1407        >>> ef == [base.effect(gain='sword')]
1408        True
1409        >>> er == [base.effect(lose='sword')]
1410        True
1411        >>> d.addConsequence('A', 'fwd', [base.effect(deactivate=True)])
1412        (2, 2)
1413        >>> ef = d.getConsequence('A', 'fwd')
1414        >>> ef == [base.effect(gain='sword'), base.effect(deactivate=True)]
1415        True
1416        >>> d.addConsequence(
1417        ...     'A',
1418        ...     'fwd',  # adding to consequence with 3 parts already
1419        ...     [  # outer list not counted because it merges
1420        ...         base.challenge(  # 1 part
1421        ...             None,
1422        ...             0,
1423        ...             [base.effect(gain=('flowers', 3))],  # 2 parts
1424        ...             [base.effect(gain=('flowers', 1))]  # 2 parts
1425        ...         )
1426        ...     ]
1427        ... )  # note indices below are inclusive; indices are 3, 4, 5, 6, 7
1428        (3, 7)
1429        """
1430        dID = self.resolveDecision(decision)
1431
1432        dest = self.destination(dID, transition)
1433
1434        info = cast(
1435            TransitionProperties,
1436            self.edges[dID, dest, transition]  # type:ignore
1437        )
1438
1439        existing = info.setdefault('consequence', [])
1440        startIndex = base.countParts(existing)
1441        existing.extend(consequence)
1442        endIndex = base.countParts(existing) - 1
1443        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:
1445    def setConsequence(
1446        self,
1447        decision: base.AnyDecisionSpecifier,
1448        transition: base.Transition,
1449        consequence: base.Consequence
1450    ) -> None:
1451        """
1452        Replaces the transition consequence for the given transition at
1453        the given decision. Any previous consequence is discarded. See
1454        `Consequence` for the structure of these. Note that this does
1455        NOT make a copy of the consequence, do that first to avoid
1456        effect-entanglement if you're copying a consequence.
1457
1458        A `MissingDecisionError` or a `MissingTransitionError` is raised
1459        if the specified decision/transition combination doesn't exist.
1460        """
1461        dID = self.resolveDecision(decision)
1462
1463        dest = self.destination(dID, transition)
1464
1465        info = cast(
1466            TransitionProperties,
1467            self.edges[dID, dest, transition]  # type:ignore
1468        )
1469
1470        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:
1472    def addEquivalence(
1473        self,
1474        requirement: base.Requirement,
1475        capabilityOrMechanismState: Union[
1476            base.Capability,
1477            Tuple[base.MechanismID, base.MechanismState]
1478        ]
1479    ) -> None:
1480        """
1481        Adds the given requirement as an equivalence for the given
1482        capability or the given mechanism state. Note that having a
1483        capability via an equivalence does not count as actually having
1484        that capability; it only counts for the purpose of satisfying
1485        `Requirement`s.
1486
1487        Note also that because a mechanism-based requirement looks up
1488        the specific mechanism locally based on a name, an equivalence
1489        defined in one location may affect mechanism requirements in
1490        other locations unless the mechanism name in the requirement is
1491        zone-qualified to be specific. But in such situations the base
1492        mechanism would have caused issues in any case.
1493        """
1494        self.equivalences.setdefault(
1495            capabilityOrMechanismState,
1496            set()
1497        ).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:
1499    def removeEquivalence(
1500        self,
1501        requirement: base.Requirement,
1502        capabilityOrMechanismState: Union[
1503            base.Capability,
1504            Tuple[base.MechanismID, base.MechanismState]
1505        ]
1506    ) -> None:
1507        """
1508        Removes an equivalence. Raises a `KeyError` if no such
1509        equivalence existed.
1510        """
1511        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:
1513    def hasAnyEquivalents(
1514        self,
1515        capabilityOrMechanismState: Union[
1516            base.Capability,
1517            Tuple[base.MechanismID, base.MechanismState]
1518        ]
1519    ) -> bool:
1520        """
1521        Returns `True` if the given capability or mechanism state has at
1522        least one equivalence.
1523        """
1524        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]:
1526    def allEquivalents(
1527        self,
1528        capabilityOrMechanismState: Union[
1529            base.Capability,
1530            Tuple[base.MechanismID, base.MechanismState]
1531        ]
1532    ) -> Set[base.Requirement]:
1533        """
1534        Returns the set of equivalences for the given capability. This is
1535        a live set which may be modified (it's probably better to use
1536        `addEquivalence` and `removeEquivalence` instead...).
1537        """
1538        return self.equivalences.setdefault(
1539            capabilityOrMechanismState,
1540            set()
1541        )

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:
1543    def reversionType(self, name: str, equivalentTo: Set[str]) -> None:
1544        """
1545        Specifies a new reversion type, so that when used in a reversion
1546        aspects set with a colon before the name, all items in the
1547        `equivalentTo` value will be added to that set. These may
1548        include other custom reversion type names (with the colon) but
1549        take care not to create an equivalence loop which would result
1550        in a crash.
1551
1552        If you re-use the same name, it will override the old equivalence
1553        for that name.
1554        """
1555        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:
1557    def addAction(
1558        self,
1559        decision: base.AnyDecisionSpecifier,
1560        action: base.Transition,
1561        requires: Optional[base.Requirement] = None,
1562        consequence: Optional[base.Consequence] = None,
1563        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
1564        annotations: Optional[List[base.Annotation]] = None,
1565    ) -> None:
1566        """
1567        Adds the given action as a possibility at the given decision. An
1568        action is just a self-edge, which can have requirements like any
1569        edge, and which can have consequences like any edge.
1570        The optional arguments are given to `setTransitionRequirement`
1571        and `setConsequence`; see those functions for descriptions
1572        of what they mean.
1573
1574        Raises a `KeyError` if a transition with the given name already
1575        exists at the given decision.
1576        """
1577        if tags is None:
1578            tags = {}
1579        if annotations is None:
1580            annotations = []
1581
1582        dID = self.resolveDecision(decision)
1583
1584        self.add_edge(
1585            dID,
1586            dID,
1587            key=action,
1588            tags=tags,
1589            annotations=annotations
1590        )
1591        self.setTransitionRequirement(dID, action, requires)
1592        if consequence is not None:
1593            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:
1595    def tagDecision(
1596        self,
1597        decision: base.AnyDecisionSpecifier,
1598        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1599        tagValue: Union[
1600            base.TagValue,
1601            type[base.NoTagValue]
1602        ] = base.NoTagValue
1603    ) -> None:
1604        """
1605        Adds a tag (or many tags from a dictionary of tags) to a
1606        decision, using `1` as the value if no value is provided. It's
1607        a `ValueError` to provide a value when a dictionary of tags is
1608        provided to set multiple tags at once.
1609
1610        Note that certain tags have special meanings:
1611
1612        - 'unconfirmed' is used for decisions that represent unconfirmed
1613            parts of the graph (this is separate from the 'unknown'
1614            and/or 'hypothesized' exploration statuses, which are only
1615            tracked in a `DiscreteExploration`, not in a `DecisionGraph`).
1616            Various methods require this tag and many also add or remove
1617            it.
1618        """
1619        if isinstance(tagOrTags, base.Tag):
1620            if tagValue is base.NoTagValue:
1621                tagValue = 1
1622
1623            # Not sure why this cast is necessary given the `if` above...
1624            tagValue = cast(base.TagValue, tagValue)
1625
1626            tagOrTags = {tagOrTags: tagValue}
1627
1628        elif tagValue is not base.NoTagValue:
1629            raise ValueError(
1630                "Provided a dictionary to update multiple tags, but"
1631                " also a tag value."
1632            )
1633
1634        dID = self.resolveDecision(decision)
1635
1636        tagsAlready = self.nodes[dID].setdefault('tags', {})
1637        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]]:
1639    def untagDecision(
1640        self,
1641        decision: base.AnyDecisionSpecifier,
1642        tag: base.Tag
1643    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1644        """
1645        Removes a tag from a decision. Returns the tag's old value if
1646        the tag was present and got removed, or `NoTagValue` if the tag
1647        wasn't present.
1648        """
1649        dID = self.resolveDecision(decision)
1650
1651        target = self.nodes[dID]['tags']
1652        try:
1653            return target.pop(tag)
1654        except KeyError:
1655            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]]]]:
1657    def decisionTags(
1658        self,
1659        decision: base.AnyDecisionSpecifier
1660    ) -> Dict[base.Tag, base.TagValue]:
1661        """
1662        Returns the dictionary of tags for a decision. Edits to the
1663        returned value will be applied to the graph.
1664        """
1665        dID = self.resolveDecision(decision)
1666
1667        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:
1669    def annotateDecision(
1670        self,
1671        decision: base.AnyDecisionSpecifier,
1672        annotationOrAnnotations: Union[
1673            base.Annotation,
1674            Sequence[base.Annotation]
1675        ]
1676    ) -> None:
1677        """
1678        Adds an annotation to a decision's annotations list.
1679        """
1680        dID = self.resolveDecision(decision)
1681
1682        if isinstance(annotationOrAnnotations, base.Annotation):
1683            annotationOrAnnotations = [annotationOrAnnotations]
1684        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]:
1686    def decisionAnnotations(
1687        self,
1688        decision: base.AnyDecisionSpecifier
1689    ) -> List[base.Annotation]:
1690        """
1691        Returns the list of annotations for the specified decision.
1692        Modifying the list affects the graph.
1693        """
1694        dID = self.resolveDecision(decision)
1695
1696        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:
1698    def tagTransition(
1699        self,
1700        decision: base.AnyDecisionSpecifier,
1701        transition: base.Transition,
1702        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1703        tagValue: Union[
1704            base.TagValue,
1705            type[base.NoTagValue]
1706        ] = base.NoTagValue
1707    ) -> None:
1708        """
1709        Adds a tag (or each tag from a dictionary) to a transition
1710        coming out of a specific decision. `1` will be used as the
1711        default value if a single tag is supplied; supplying a tag value
1712        when providing a dictionary of multiple tags to update is a
1713        `ValueError`.
1714
1715        Note that certain transition tags have special meanings:
1716        - 'trigger' causes any actions (but not normal transitions) that
1717            it applies to to be automatically triggered when
1718            `advanceSituation` is called and the decision they're
1719            attached to is active in the new situation (as long as the
1720            action's requirements are met). This happens once per
1721            situation; use 'wait' steps to re-apply triggers.
1722        """
1723        dID = self.resolveDecision(decision)
1724
1725        dest = self.destination(dID, transition)
1726        if isinstance(tagOrTags, base.Tag):
1727            if tagValue is base.NoTagValue:
1728                tagValue = 1
1729
1730            # Not sure why this is necessary given the `if` above...
1731            tagValue = cast(base.TagValue, tagValue)
1732
1733            tagOrTags = {tagOrTags: tagValue}
1734        elif tagValue is not base.NoTagValue:
1735            raise ValueError(
1736                "Provided a dictionary to update multiple tags, but"
1737                " also a tag value."
1738            )
1739
1740        info = cast(
1741            TransitionProperties,
1742            self.edges[dID, dest, transition]  # type:ignore
1743        )
1744
1745        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:
1747    def untagTransition(
1748        self,
1749        decision: base.AnyDecisionSpecifier,
1750        transition: base.Transition,
1751        tagOrTags: Union[base.Tag, Set[base.Tag]]
1752    ) -> None:
1753        """
1754        Removes a tag (or each tag in a set) from a transition coming out
1755        of a specific decision. Raises a `KeyError` if (one of) the
1756        specified tag(s) is not currently applied to the specified
1757        transition.
1758        """
1759        dID = self.resolveDecision(decision)
1760
1761        dest = self.destination(dID, transition)
1762        if isinstance(tagOrTags, base.Tag):
1763            tagOrTags = {tagOrTags}
1764
1765        info = cast(
1766            TransitionProperties,
1767            self.edges[dID, dest, transition]  # type:ignore
1768        )
1769        tagsAlready = info.setdefault('tags', {})
1770
1771        for tag in tagOrTags:
1772            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]]]]:
1774    def transitionTags(
1775        self,
1776        decision: base.AnyDecisionSpecifier,
1777        transition: base.Transition
1778    ) -> Dict[base.Tag, base.TagValue]:
1779        """
1780        Returns the dictionary of tags for a transition. Edits to the
1781        returned dictionary will be applied to the graph.
1782        """
1783        dID = self.resolveDecision(decision)
1784
1785        dest = self.destination(dID, transition)
1786        info = cast(
1787            TransitionProperties,
1788            self.edges[dID, dest, transition]  # type:ignore
1789        )
1790        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:
1792    def annotateTransition(
1793        self,
1794        decision: base.AnyDecisionSpecifier,
1795        transition: base.Transition,
1796        annotations: Union[base.Annotation, Sequence[base.Annotation]]
1797    ) -> None:
1798        """
1799        Adds an annotation (or a sequence of annotations) to a
1800        transition's annotations list.
1801        """
1802        dID = self.resolveDecision(decision)
1803
1804        dest = self.destination(dID, transition)
1805        if isinstance(annotations, base.Annotation):
1806            annotations = [annotations]
1807        info = cast(
1808            TransitionProperties,
1809            self.edges[dID, dest, transition]  # type:ignore
1810        )
1811        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]:
1813    def transitionAnnotations(
1814        self,
1815        decision: base.AnyDecisionSpecifier,
1816        transition: base.Transition
1817    ) -> List[base.Annotation]:
1818        """
1819        Returns the annotation list for a specific transition at a
1820        specific decision. Editing the list affects the graph.
1821        """
1822        dID = self.resolveDecision(decision)
1823
1824        dest = self.destination(dID, transition)
1825        info = cast(
1826            TransitionProperties,
1827            self.edges[dID, dest, transition]  # type:ignore
1828        )
1829        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:
1831    def annotateZone(
1832        self,
1833        zone: base.Zone,
1834        annotations: Union[base.Annotation, Sequence[base.Annotation]]
1835    ) -> None:
1836        """
1837        Adds an annotation (or many annotations from a sequence) to a
1838        zone.
1839
1840        Raises a `MissingZoneError` if the specified zone does not exist.
1841        """
1842        if zone not in self.zones:
1843            raise MissingZoneError(
1844                f"Can't add annotation(s) to zone {zone!r} because that"
1845                f" zone doesn't exist yet."
1846            )
1847
1848        if isinstance(annotations, base.Annotation):
1849            annotations = [ annotations ]
1850
1851        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]:
1853    def zoneAnnotations(self, zone: base.Zone) -> List[base.Annotation]:
1854        """
1855        Returns the list of annotations for the specified zone (empty if
1856        none have been added yet).
1857        """
1858        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:
1860    def tagZone(
1861        self,
1862        zone: base.Zone,
1863        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
1864        tagValue: Union[
1865            base.TagValue,
1866            type[base.NoTagValue]
1867        ] = base.NoTagValue
1868    ) -> None:
1869        """
1870        Adds a tag (or many tags from a dictionary of tags) to a
1871        zone, using `1` as the value if no value is provided. It's
1872        a `ValueError` to provide a value when a dictionary of tags is
1873        provided to set multiple tags at once.
1874
1875        Raises a `MissingZoneError` if the specified zone does not exist.
1876        """
1877        if zone not in self.zones:
1878            raise MissingZoneError(
1879                f"Can't add tag(s) to zone {zone!r} because that zone"
1880                f" doesn't exist yet."
1881            )
1882
1883        if isinstance(tagOrTags, base.Tag):
1884            if tagValue is base.NoTagValue:
1885                tagValue = 1
1886
1887            # Not sure why this cast is necessary given the `if` above...
1888            tagValue = cast(base.TagValue, tagValue)
1889
1890            tagOrTags = {tagOrTags: tagValue}
1891
1892        elif tagValue is not base.NoTagValue:
1893            raise ValueError(
1894                "Provided a dictionary to update multiple tags, but"
1895                " also a tag value."
1896            )
1897
1898        tagsAlready = self.zones[zone].tags
1899        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]]:
1901    def untagZone(
1902        self,
1903        zone: base.Zone,
1904        tag: base.Tag
1905    ) -> Union[base.TagValue, type[base.NoTagValue]]:
1906        """
1907        Removes a tag from a zone. Returns the tag's old value if the
1908        tag was present and got removed, or `NoTagValue` if the tag
1909        wasn't present.
1910
1911        Raises a `MissingZoneError` if the specified zone does not exist.
1912        """
1913        if zone not in self.zones:
1914            raise MissingZoneError(
1915                f"Can't remove tag {tag!r} from zone {zone!r} because"
1916                f" that zone doesn't exist yet."
1917            )
1918        target = self.zones[zone].tags
1919        try:
1920            return target.pop(tag)
1921        except KeyError:
1922            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]]]]:
1924    def zoneTags(
1925        self,
1926        zone: base.Zone
1927    ) -> Dict[base.Tag, base.TagValue]:
1928        """
1929        Returns the dictionary of tags for a zone. Edits to the returned
1930        value will be applied to the graph. Returns an empty tags
1931        dictionary if called on a zone that didn't have any tags
1932        previously, but raises a `MissingZoneError` if attempting to get
1933        tags for a zone which does not exist.
1934
1935        For example:
1936
1937        >>> g = DecisionGraph()
1938        >>> g.addDecision('A')
1939        0
1940        >>> g.addDecision('B')
1941        1
1942        >>> g.createZone('Zone')
1943        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1944 annotations=[])
1945        >>> g.tagZone('Zone', 'color', 'blue')
1946        >>> g.tagZone(
1947        ...     'Zone',
1948        ...     {'shape': 'square', 'color': 'red', 'sound': 'loud'}
1949        ... )
1950        >>> g.untagZone('Zone', 'sound')
1951        'loud'
1952        >>> g.zoneTags('Zone')
1953        {'color': 'red', 'shape': 'square'}
1954        """
1955        if zone in self.zones:
1956            return self.zones[zone].tags
1957        else:
1958            raise MissingZoneError(
1959                f"Tags for zone {zone!r} don't exist because that"
1960                f" zone has not been created yet."
1961            )

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:
1963    def createZone(self, zone: base.Zone, level: int = 0) -> base.ZoneInfo:
1964        """
1965        Creates an empty zone with the given name at the given level
1966        (default 0). Raises a `ZoneCollisionError` if that zone name is
1967        already in use (at any level), including if it's in use by a
1968        decision.
1969
1970        Raises an `InvalidLevelError` if the level value is less than 0.
1971
1972        Returns the `ZoneInfo` for the new blank zone.
1973
1974        For example:
1975
1976        >>> d = DecisionGraph()
1977        >>> d.createZone('Z', 0)
1978        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1979 annotations=[])
1980        >>> d.getZoneInfo('Z')
1981        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1982 annotations=[])
1983        >>> d.createZone('Z2', 0)
1984        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
1985 annotations=[])
1986        >>> d.createZone('Z3', -1)  # level -1 is not valid (must be >= 0)
1987        Traceback (most recent call last):
1988        ...
1989        exploration.core.InvalidLevelError...
1990        >>> d.createZone('Z2')  # Name Z2 is already in use
1991        Traceback (most recent call last):
1992        ...
1993        exploration.core.ZoneCollisionError...
1994        """
1995        if level < 0:
1996            raise InvalidLevelError(
1997                "Cannot create a zone with a negative level."
1998            )
1999        if zone in self.zones:
2000            raise ZoneCollisionError(f"Zone {zone!r} already exists.")
2001        if zone in self:
2002            raise ZoneCollisionError(
2003                f"A decision named {zone!r} already exists, so a zone"
2004                f" with that name cannot be created."
2005            )
2006        info: base.ZoneInfo = base.ZoneInfo(
2007            level=level,
2008            parents=set(),
2009            contents=set(),
2010            tags={},
2011            annotations=[]
2012        )
2013        self.zones[zone] = info
2014        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]:
2016    def getZoneInfo(self, zone: base.Zone) -> Optional[base.ZoneInfo]:
2017        """
2018        Returns the `ZoneInfo` (level, parents, and contents) for the
2019        specified zone, or `None` if that zone does not exist.
2020
2021        For example:
2022
2023        >>> d = DecisionGraph()
2024        >>> d.createZone('Z', 0)
2025        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2026 annotations=[])
2027        >>> d.getZoneInfo('Z')
2028        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2029 annotations=[])
2030        >>> d.createZone('Z2', 0)
2031        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2032 annotations=[])
2033        >>> d.getZoneInfo('Z2')
2034        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2035 annotations=[])
2036        """
2037        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:
2039    def deleteZone(self, zone: base.Zone) -> base.ZoneInfo:
2040        """
2041        Deletes the specified zone, returning a `ZoneInfo` object with
2042        the information on the level, parents, and contents of that zone.
2043
2044        Raises a `MissingZoneError` if the zone in question does not
2045        exist.
2046
2047        For example:
2048
2049        >>> d = DecisionGraph()
2050        >>> d.createZone('Z', 0)
2051        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2052 annotations=[])
2053        >>> d.getZoneInfo('Z')
2054        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2055 annotations=[])
2056        >>> d.deleteZone('Z')
2057        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2058 annotations=[])
2059        >>> d.getZoneInfo('Z') is None  # no info any more
2060        True
2061        >>> d.deleteZone('Z')  # can't re-delete
2062        Traceback (most recent call last):
2063        ...
2064        exploration.core.MissingZoneError...
2065        """
2066        info = self.getZoneInfo(zone)
2067        if info is None:
2068            raise MissingZoneError(
2069                f"Cannot delete zone {zone!r}: it does not exist."
2070            )
2071        for sub in info.contents:
2072            if 'zones' in self.nodes[sub]:
2073                try:
2074                    self.nodes[sub]['zones'].remove(zone)
2075                except KeyError:
2076                    pass
2077        del self.zones[zone]
2078        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:
2080    def addDecisionToZone(
2081        self,
2082        decision: base.AnyDecisionSpecifier,
2083        zone: base.Zone
2084    ) -> None:
2085        """
2086        Adds a decision directly to a zone. Should normally only be used
2087        with level-0 zones. Raises a `MissingZoneError` if the specified
2088        zone did not already exist.
2089
2090        For example:
2091
2092        >>> d = DecisionGraph()
2093        >>> d.addDecision('A')
2094        0
2095        >>> d.addDecision('B')
2096        1
2097        >>> d.addDecision('C')
2098        2
2099        >>> d.createZone('Z', 0)
2100        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2101 annotations=[])
2102        >>> d.addDecisionToZone('A', 'Z')
2103        >>> d.getZoneInfo('Z')
2104        ZoneInfo(level=0, parents=set(), contents={0}, tags={},\
2105 annotations=[])
2106        >>> d.addDecisionToZone('B', 'Z')
2107        >>> d.getZoneInfo('Z')
2108        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2109 annotations=[])
2110        """
2111        dID = self.resolveDecision(decision)
2112
2113        if zone not in self.zones:
2114            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2115
2116        self.zones[zone].contents.add(dID)
2117        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:
2119    def removeDecisionFromZone(
2120        self,
2121        decision: base.AnyDecisionSpecifier,
2122        zone: base.Zone
2123    ) -> bool:
2124        """
2125        Removes a decision from a zone if it had been in it, returning
2126        True if that decision had been in that zone, and False if it was
2127        not in that zone, including if that zone didn't exist.
2128
2129        Note that this only removes a decision from direct zone
2130        membership. If the decision is a member of one or more zones
2131        which are (directly or indirectly) sub-zones of the target zone,
2132        the decision will remain in those zones, and will still be
2133        indirectly part of the target zone afterwards.
2134
2135        Examples:
2136
2137        >>> g = DecisionGraph()
2138        >>> g.addDecision('A')
2139        0
2140        >>> g.addDecision('B')
2141        1
2142        >>> g.createZone('level0', 0)
2143        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2144 annotations=[])
2145        >>> g.createZone('level1', 1)
2146        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2147 annotations=[])
2148        >>> g.createZone('level2', 2)
2149        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2150 annotations=[])
2151        >>> g.createZone('level3', 3)
2152        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2153 annotations=[])
2154        >>> g.addDecisionToZone('A', 'level0')
2155        >>> g.addDecisionToZone('B', 'level0')
2156        >>> g.addZoneToZone('level0', 'level1')
2157        >>> g.addZoneToZone('level1', 'level2')
2158        >>> g.addZoneToZone('level2', 'level3')
2159        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2160        >>> g.removeDecisionFromZone('A', 'level1')
2161        False
2162        >>> g.zoneParents(0)
2163        {'level0'}
2164        >>> g.removeDecisionFromZone('A', 'level0')
2165        True
2166        >>> g.zoneParents(0)
2167        set()
2168        >>> g.removeDecisionFromZone('A', 'level0')
2169        False
2170        >>> g.removeDecisionFromZone('B', 'level0')
2171        True
2172        >>> g.zoneParents(1)
2173        {'level2'}
2174        >>> g.removeDecisionFromZone('B', 'level0')
2175        False
2176        >>> g.removeDecisionFromZone('B', 'level2')
2177        True
2178        >>> g.zoneParents(1)
2179        set()
2180        """
2181        dID = self.resolveDecision(decision)
2182
2183        if zone not in self.zones:
2184            return False
2185
2186        info = self.zones[zone]
2187        if dID not in info.contents:
2188            return False
2189        else:
2190            info.contents.remove(dID)
2191            try:
2192                self.nodes[dID]['zones'].remove(zone)
2193            except KeyError:
2194                pass
2195            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:
2197    def addZoneToZone(
2198        self,
2199        addIt: base.Zone,
2200        addTo: base.Zone
2201    ) -> None:
2202        """
2203        Adds a zone to another zone. The `addIt` one must be at a
2204        strictly lower level than the `addTo` zone, or an
2205        `InvalidLevelError` will be raised.
2206
2207        If the zone to be added didn't already exist, it is created at
2208        one level below the target zone. Similarly, if the zone being
2209        added to didn't already exist, it is created at one level above
2210        the target zone. If neither existed, a `MissingZoneError` will
2211        be raised.
2212
2213        For example:
2214
2215        >>> d = DecisionGraph()
2216        >>> d.addDecision('A')
2217        0
2218        >>> d.addDecision('B')
2219        1
2220        >>> d.addDecision('C')
2221        2
2222        >>> d.createZone('Z', 0)
2223        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2224 annotations=[])
2225        >>> d.addDecisionToZone('A', 'Z')
2226        >>> d.addDecisionToZone('B', 'Z')
2227        >>> d.getZoneInfo('Z')
2228        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2229 annotations=[])
2230        >>> d.createZone('Z2', 0)
2231        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2232 annotations=[])
2233        >>> d.addDecisionToZone('B', 'Z2')
2234        >>> d.addDecisionToZone('C', 'Z2')
2235        >>> d.getZoneInfo('Z2')
2236        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2237 annotations=[])
2238        >>> d.createZone('l1Z', 1)
2239        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2240 annotations=[])
2241        >>> d.createZone('l2Z', 2)
2242        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2243 annotations=[])
2244        >>> d.addZoneToZone('Z', 'l1Z')
2245        >>> d.getZoneInfo('Z')
2246        ZoneInfo(level=0, parents={'l1Z'}, contents={0, 1}, tags={},\
2247 annotations=[])
2248        >>> d.getZoneInfo('l1Z')
2249        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2250 annotations=[])
2251        >>> d.addZoneToZone('l1Z', 'l2Z')
2252        >>> d.getZoneInfo('l1Z')
2253        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2254 annotations=[])
2255        >>> d.getZoneInfo('l2Z')
2256        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2257 annotations=[])
2258        >>> d.addZoneToZone('Z2', 'l2Z')
2259        >>> d.getZoneInfo('Z2')
2260        ZoneInfo(level=0, parents={'l2Z'}, contents={1, 2}, tags={},\
2261 annotations=[])
2262        >>> l2i = d.getZoneInfo('l2Z')
2263        >>> l2i.level
2264        2
2265        >>> l2i.parents
2266        set()
2267        >>> sorted(l2i.contents)
2268        ['Z2', 'l1Z']
2269        >>> d.addZoneToZone('NZ', 'NZ2')
2270        Traceback (most recent call last):
2271        ...
2272        exploration.core.MissingZoneError...
2273        >>> d.addZoneToZone('Z', 'l1Z2')
2274        >>> zi = d.getZoneInfo('Z')
2275        >>> zi.level
2276        0
2277        >>> sorted(zi.parents)
2278        ['l1Z', 'l1Z2']
2279        >>> sorted(zi.contents)
2280        [0, 1]
2281        >>> d.getZoneInfo('l1Z2')
2282        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2283 annotations=[])
2284        >>> d.addZoneToZone('NZ', 'l1Z')
2285        >>> d.getZoneInfo('NZ')
2286        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2287 annotations=[])
2288        >>> zi = d.getZoneInfo('l1Z')
2289        >>> zi.level
2290        1
2291        >>> zi.parents
2292        {'l2Z'}
2293        >>> sorted(zi.contents)
2294        ['NZ', 'Z']
2295        """
2296        # Create one or the other (but not both) if they're missing
2297        addInfo = self.getZoneInfo(addIt)
2298        toInfo = self.getZoneInfo(addTo)
2299        if addInfo is None and toInfo is None:
2300            raise MissingZoneError(
2301                f"Cannot add zone {addIt!r} to zone {addTo!r}: neither"
2302                f" exists already."
2303            )
2304
2305        # Create missing addIt
2306        elif addInfo is None:
2307            toInfo = cast(base.ZoneInfo, toInfo)
2308            newLevel = toInfo.level - 1
2309            if newLevel < 0:
2310                raise InvalidLevelError(
2311                    f"Zone {addTo!r} is at level {toInfo.level} and so"
2312                    f" a new zone cannot be added underneath it."
2313                )
2314            addInfo = self.createZone(addIt, newLevel)
2315
2316        # Create missing addTo
2317        elif toInfo is None:
2318            addInfo = cast(base.ZoneInfo, addInfo)
2319            newLevel = addInfo.level + 1
2320            if newLevel < 0:
2321                raise InvalidLevelError(
2322                    f"Zone {addIt!r} is at level {addInfo.level} (!!!)"
2323                    f" and so a new zone cannot be added above it."
2324                )
2325            toInfo = self.createZone(addTo, newLevel)
2326
2327        # Now both addInfo and toInfo are defined
2328        if addInfo.level >= toInfo.level:
2329            raise InvalidLevelError(
2330                f"Cannot add zone {addIt!r} at level {addInfo.level}"
2331                f" to zone {addTo!r} at level {toInfo.level}: zones can"
2332                f" only contain zones of lower levels."
2333            )
2334
2335        # Now both addInfo and toInfo are defined
2336        toInfo.contents.add(addIt)
2337        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:
2339    def removeZoneFromZone(
2340        self,
2341        removeIt: base.Zone,
2342        removeFrom: base.Zone
2343    ) -> bool:
2344        """
2345        Removes a zone from a zone if it had been in it, returning True
2346        if that zone had been in that zone, and False if it was not in
2347        that zone, including if either zone did not exist.
2348
2349        For example:
2350
2351        >>> d = DecisionGraph()
2352        >>> d.createZone('Z', 0)
2353        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2354 annotations=[])
2355        >>> d.createZone('Z2', 0)
2356        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2357 annotations=[])
2358        >>> d.createZone('l1Z', 1)
2359        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2360 annotations=[])
2361        >>> d.createZone('l2Z', 2)
2362        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2363 annotations=[])
2364        >>> d.addZoneToZone('Z', 'l1Z')
2365        >>> d.addZoneToZone('l1Z', 'l2Z')
2366        >>> d.getZoneInfo('Z')
2367        ZoneInfo(level=0, parents={'l1Z'}, contents=set(), tags={},\
2368 annotations=[])
2369        >>> d.getZoneInfo('l1Z')
2370        ZoneInfo(level=1, parents={'l2Z'}, contents={'Z'}, tags={},\
2371 annotations=[])
2372        >>> d.getZoneInfo('l2Z')
2373        ZoneInfo(level=2, parents=set(), contents={'l1Z'}, tags={},\
2374 annotations=[])
2375        >>> d.removeZoneFromZone('l1Z', 'l2Z')
2376        True
2377        >>> d.getZoneInfo('l1Z')
2378        ZoneInfo(level=1, parents=set(), contents={'Z'}, tags={},\
2379 annotations=[])
2380        >>> d.getZoneInfo('l2Z')
2381        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2382 annotations=[])
2383        >>> d.removeZoneFromZone('Z', 'l1Z')
2384        True
2385        >>> d.getZoneInfo('Z')
2386        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2387 annotations=[])
2388        >>> d.getZoneInfo('l1Z')
2389        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2390 annotations=[])
2391        >>> d.removeZoneFromZone('Z', 'l1Z')
2392        False
2393        >>> d.removeZoneFromZone('Z', 'madeup')
2394        False
2395        >>> d.removeZoneFromZone('nope', 'madeup')
2396        False
2397        >>> d.removeZoneFromZone('nope', 'l1Z')
2398        False
2399        """
2400        remInfo = self.getZoneInfo(removeIt)
2401        fromInfo = self.getZoneInfo(removeFrom)
2402
2403        if remInfo is None or fromInfo is None:
2404            return False
2405
2406        if removeIt not in fromInfo.contents:
2407            return False
2408
2409        remInfo.parents.remove(removeFrom)
2410        fromInfo.contents.remove(removeIt)
2411        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]:
2413    def decisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2414        """
2415        Returns a set of all decisions included directly in the given
2416        zone, not counting decisions included via intermediate
2417        sub-zones (see `allDecisionsInZone` to include those).
2418
2419        Raises a `MissingZoneError` if the specified zone does not
2420        exist.
2421
2422        The returned set is a copy, not a live editable set.
2423
2424        For example:
2425
2426        >>> d = DecisionGraph()
2427        >>> d.addDecision('A')
2428        0
2429        >>> d.addDecision('B')
2430        1
2431        >>> d.addDecision('C')
2432        2
2433        >>> d.createZone('Z', 0)
2434        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2435 annotations=[])
2436        >>> d.addDecisionToZone('A', 'Z')
2437        >>> d.addDecisionToZone('B', 'Z')
2438        >>> d.getZoneInfo('Z')
2439        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2440 annotations=[])
2441        >>> d.decisionsInZone('Z')
2442        {0, 1}
2443        >>> d.createZone('Z2', 0)
2444        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2445 annotations=[])
2446        >>> d.addDecisionToZone('B', 'Z2')
2447        >>> d.addDecisionToZone('C', 'Z2')
2448        >>> d.getZoneInfo('Z2')
2449        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2450 annotations=[])
2451        >>> d.decisionsInZone('Z')
2452        {0, 1}
2453        >>> d.decisionsInZone('Z2')
2454        {1, 2}
2455        >>> d.createZone('l1Z', 1)
2456        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2457 annotations=[])
2458        >>> d.addZoneToZone('Z', 'l1Z')
2459        >>> d.decisionsInZone('Z')
2460        {0, 1}
2461        >>> d.decisionsInZone('l1Z')
2462        set()
2463        >>> d.decisionsInZone('madeup')
2464        Traceback (most recent call last):
2465        ...
2466        exploration.core.MissingZoneError...
2467        >>> zDec = d.decisionsInZone('Z')
2468        >>> zDec.add(2)  # won't affect the zone
2469        >>> zDec
2470        {0, 1, 2}
2471        >>> d.decisionsInZone('Z')
2472        {0, 1}
2473        """
2474        info = self.getZoneInfo(zone)
2475        if info is None:
2476            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2477
2478        # Everything that's not a zone must be a decision
2479        return {
2480            item
2481            for item in info.contents
2482            if isinstance(item, base.DecisionID)
2483        }

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]:
2485    def subZones(self, zone: base.Zone) -> Set[base.Zone]:
2486        """
2487        Returns the set of all immediate sub-zones of the given zone.
2488        Will be an empty set if there are no sub-zones; raises a
2489        `MissingZoneError` if the specified zone does not exit.
2490
2491        The returned set is a copy, not a live editable set.
2492
2493        For example:
2494
2495        >>> d = DecisionGraph()
2496        >>> d.createZone('Z', 0)
2497        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2498 annotations=[])
2499        >>> d.subZones('Z')
2500        set()
2501        >>> d.createZone('l1Z', 1)
2502        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2503 annotations=[])
2504        >>> d.addZoneToZone('Z', 'l1Z')
2505        >>> d.subZones('Z')
2506        set()
2507        >>> d.subZones('l1Z')
2508        {'Z'}
2509        >>> s = d.subZones('l1Z')
2510        >>> s.add('Q')  # doesn't affect the zone
2511        >>> sorted(s)
2512        ['Q', 'Z']
2513        >>> d.subZones('l1Z')
2514        {'Z'}
2515        >>> d.subZones('madeup')
2516        Traceback (most recent call last):
2517        ...
2518        exploration.core.MissingZoneError...
2519        """
2520        info = self.getZoneInfo(zone)
2521        if info is None:
2522            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2523
2524        # Sub-zones will appear in self.zones
2525        return {
2526            item
2527            for item in info.contents
2528            if isinstance(item, base.Zone)
2529        }

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]:
2531    def allDecisionsInZone(self, zone: base.Zone) -> Set[base.DecisionID]:
2532        """
2533        Returns a set containing all decisions in the given zone,
2534        including those included via sub-zones.
2535
2536        Raises a `MissingZoneError` if the specified zone does not
2537        exist.`
2538
2539        For example:
2540
2541        >>> d = DecisionGraph()
2542        >>> d.addDecision('A')
2543        0
2544        >>> d.addDecision('B')
2545        1
2546        >>> d.addDecision('C')
2547        2
2548        >>> d.createZone('Z', 0)
2549        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2550 annotations=[])
2551        >>> d.addDecisionToZone('A', 'Z')
2552        >>> d.addDecisionToZone('B', 'Z')
2553        >>> d.getZoneInfo('Z')
2554        ZoneInfo(level=0, parents=set(), contents={0, 1}, tags={},\
2555 annotations=[])
2556        >>> d.decisionsInZone('Z')
2557        {0, 1}
2558        >>> d.allDecisionsInZone('Z')
2559        {0, 1}
2560        >>> d.createZone('Z2', 0)
2561        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2562 annotations=[])
2563        >>> d.addDecisionToZone('B', 'Z2')
2564        >>> d.addDecisionToZone('C', 'Z2')
2565        >>> d.getZoneInfo('Z2')
2566        ZoneInfo(level=0, parents=set(), contents={1, 2}, tags={},\
2567 annotations=[])
2568        >>> d.decisionsInZone('Z')
2569        {0, 1}
2570        >>> d.decisionsInZone('Z2')
2571        {1, 2}
2572        >>> d.allDecisionsInZone('Z2')
2573        {1, 2}
2574        >>> d.createZone('l1Z', 1)
2575        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2576 annotations=[])
2577        >>> d.createZone('l2Z', 2)
2578        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2579 annotations=[])
2580        >>> d.addZoneToZone('Z', 'l1Z')
2581        >>> d.addZoneToZone('l1Z', 'l2Z')
2582        >>> d.addZoneToZone('Z2', 'l2Z')
2583        >>> d.decisionsInZone('Z')
2584        {0, 1}
2585        >>> d.decisionsInZone('Z2')
2586        {1, 2}
2587        >>> d.decisionsInZone('l1Z')
2588        set()
2589        >>> d.allDecisionsInZone('l1Z')
2590        {0, 1}
2591        >>> d.allDecisionsInZone('l2Z')
2592        {0, 1, 2}
2593        """
2594        result: Set[base.DecisionID] = set()
2595        info = self.getZoneInfo(zone)
2596        if info is None:
2597            raise MissingZoneError(f"Zone {zone!r} does not exist.")
2598
2599        for item in info.contents:
2600            if isinstance(item, base.Zone):
2601                # This can't be an error because of the condition above
2602                result |= self.allDecisionsInZone(item)
2603            else:  # it's a decision
2604                result.add(item)
2605
2606        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:
2608    def zoneHierarchyLevel(self, zone: base.Zone) -> int:
2609        """
2610        Returns the hierarchy level of the given zone, as stored in its
2611        zone info.
2612
2613        Raises a `MissingZoneError` if the specified zone does not
2614        exist.
2615
2616        For example:
2617
2618        >>> d = DecisionGraph()
2619        >>> d.createZone('Z', 0)
2620        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2621 annotations=[])
2622        >>> d.createZone('l1Z', 1)
2623        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2624 annotations=[])
2625        >>> d.createZone('l5Z', 5)
2626        ZoneInfo(level=5, parents=set(), contents=set(), tags={},\
2627 annotations=[])
2628        >>> d.zoneHierarchyLevel('Z')
2629        0
2630        >>> d.zoneHierarchyLevel('l1Z')
2631        1
2632        >>> d.zoneHierarchyLevel('l5Z')
2633        5
2634        >>> d.zoneHierarchyLevel('madeup')
2635        Traceback (most recent call last):
2636        ...
2637        exploration.core.MissingZoneError...
2638        """
2639        info = self.getZoneInfo(zone)
2640        if info is None:
2641            raise MissingZoneError(f"Zone {zone!r} dose not exist.")
2642
2643        return info.level

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

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]:
2645    def zoneParents(
2646        self,
2647        zoneOrDecision: Union[base.Zone, base.DecisionID]
2648    ) -> Set[base.Zone]:
2649        """
2650        Returns the set of all zones which directly contain the target
2651        zone or decision.
2652
2653        Raises a `MissingDecisionError` if the target is neither a valid
2654        zone nor a valid decision.
2655
2656        Returns a copy, not a live editable set.
2657
2658        Example:
2659
2660        >>> g = DecisionGraph()
2661        >>> g.addDecision('A')
2662        0
2663        >>> g.addDecision('B')
2664        1
2665        >>> g.createZone('level0', 0)
2666        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2667 annotations=[])
2668        >>> g.createZone('level1', 1)
2669        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2670 annotations=[])
2671        >>> g.createZone('level2', 2)
2672        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2673 annotations=[])
2674        >>> g.createZone('level3', 3)
2675        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2676 annotations=[])
2677        >>> g.addDecisionToZone('A', 'level0')
2678        >>> g.addDecisionToZone('B', 'level0')
2679        >>> g.addZoneToZone('level0', 'level1')
2680        >>> g.addZoneToZone('level1', 'level2')
2681        >>> g.addZoneToZone('level2', 'level3')
2682        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2683        >>> sorted(g.zoneParents(0))
2684        ['level0']
2685        >>> sorted(g.zoneParents(1))
2686        ['level0', 'level2']
2687        """
2688        if zoneOrDecision in self.zones:
2689            zoneOrDecision = cast(base.Zone, zoneOrDecision)
2690            info = cast(base.ZoneInfo, self.getZoneInfo(zoneOrDecision))
2691            return copy.copy(info.parents)
2692        elif zoneOrDecision in self:
2693            return self.nodes[zoneOrDecision].get('zones', set())
2694        else:
2695            raise MissingDecisionError(
2696                f"Name {zoneOrDecision!r} is neither a valid zone nor a"
2697                f" valid decision."
2698            )

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()) -> Set[str]:
2700    def zoneAncestors(
2701        self,
2702        zoneOrDecision: Union[base.Zone, base.DecisionID],
2703        exclude: Set[base.Zone] = set()
2704    ) -> Set[base.Zone]:
2705        """
2706        Returns the set of zones which contain the target zone or
2707        decision, either directly or indirectly. The target is not
2708        included in the set.
2709
2710        Any ones listed in the `exclude` set are also excluded, as are
2711        any of their ancestors which are not also ancestors of the
2712        target zone via another path of inclusion.
2713
2714        Raises a `MissingDecisionError` if the target is nether a valid
2715        zone nor a valid decision.
2716
2717        Example:
2718
2719        >>> g = DecisionGraph()
2720        >>> g.addDecision('A')
2721        0
2722        >>> g.addDecision('B')
2723        1
2724        >>> g.createZone('level0', 0)
2725        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2726 annotations=[])
2727        >>> g.createZone('level1', 1)
2728        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2729 annotations=[])
2730        >>> g.createZone('level2', 2)
2731        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2732 annotations=[])
2733        >>> g.createZone('level3', 3)
2734        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2735 annotations=[])
2736        >>> g.addDecisionToZone('A', 'level0')
2737        >>> g.addDecisionToZone('B', 'level0')
2738        >>> g.addZoneToZone('level0', 'level1')
2739        >>> g.addZoneToZone('level1', 'level2')
2740        >>> g.addZoneToZone('level2', 'level3')
2741        >>> g.addDecisionToZone('B', 'level2')  # Direct w/ skips
2742        >>> sorted(g.zoneAncestors(0))
2743        ['level0', 'level1', 'level2', 'level3']
2744        >>> sorted(g.zoneAncestors(1))
2745        ['level0', 'level1', 'level2', 'level3']
2746        >>> sorted(g.zoneParents(0))
2747        ['level0']
2748        >>> sorted(g.zoneParents(1))
2749        ['level0', 'level2']
2750        """
2751        # Copy is important here!
2752        result = set(self.zoneParents(zoneOrDecision))
2753        result -= exclude
2754        for parent in copy.copy(result):
2755            # Recursively dig up ancestors, but exclude
2756            # results-so-far to avoid re-enumerating when there are
2757            # multiple braided inclusion paths.
2758            result |= self.zoneAncestors(parent, result | exclude)
2759
2760        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.

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']
def zoneEdges( self, zone: str) -> Optional[Tuple[Set[Tuple[int, str]], Set[Tuple[int, str]]]]:
2762    def zoneEdges(self, zone: base.Zone) -> Optional[
2763        Tuple[
2764            Set[Tuple[base.DecisionID, base.Transition]],
2765            Set[Tuple[base.DecisionID, base.Transition]]
2766        ]
2767    ]:
2768        """
2769        Given a zone to look at, finds all of the transitions which go
2770        out of and into that zone, ignoring internal transitions between
2771        decisions in the zone. This includes all decisions in sub-zones.
2772        The return value is a pair of sets for outgoing and then
2773        incoming transitions, where each transition is specified as a
2774        (sourceID, transitionName) pair.
2775
2776        Returns `None` if the target zone isn't yet fully defined.
2777
2778        Note that this takes time proportional to *all* edges plus *all*
2779        nodes in the graph no matter how large or small the zone in
2780        question is.
2781
2782        >>> g = DecisionGraph()
2783        >>> g.addDecision('A')
2784        0
2785        >>> g.addDecision('B')
2786        1
2787        >>> g.addDecision('C')
2788        2
2789        >>> g.addDecision('D')
2790        3
2791        >>> g.addTransition('A', 'up', 'B', 'down')
2792        >>> g.addTransition('B', 'right', 'C', 'left')
2793        >>> g.addTransition('C', 'down', 'D', 'up')
2794        >>> g.addTransition('D', 'left', 'A', 'right')
2795        >>> g.addTransition('A', 'tunnel', 'C', 'tunnel')
2796        >>> g.createZone('Z', 0)
2797        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2798 annotations=[])
2799        >>> g.createZone('ZZ', 1)
2800        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2801 annotations=[])
2802        >>> g.addZoneToZone('Z', 'ZZ')
2803        >>> g.addDecisionToZone('A', 'Z')
2804        >>> g.addDecisionToZone('B', 'Z')
2805        >>> g.addDecisionToZone('D', 'ZZ')
2806        >>> outgoing, incoming = g.zoneEdges('Z')  # TODO: Sort for testing
2807        >>> sorted(outgoing)
2808        [(0, 'right'), (0, 'tunnel'), (1, 'right')]
2809        >>> sorted(incoming)
2810        [(2, 'left'), (2, 'tunnel'), (3, 'left')]
2811        >>> outgoing, incoming = g.zoneEdges('ZZ')
2812        >>> sorted(outgoing)
2813        [(0, 'tunnel'), (1, 'right'), (3, 'up')]
2814        >>> sorted(incoming)
2815        [(2, 'down'), (2, 'left'), (2, 'tunnel')]
2816        >>> g.zoneEdges('madeup') is None
2817        True
2818        """
2819        # Find the interior nodes
2820        try:
2821            interior = self.allDecisionsInZone(zone)
2822        except MissingZoneError:
2823            return None
2824
2825        # Set up our result
2826        results: Tuple[
2827            Set[Tuple[base.DecisionID, base.Transition]],
2828            Set[Tuple[base.DecisionID, base.Transition]]
2829        ] = (set(), set())
2830
2831        # Because finding incoming edges requires searching the entire
2832        # graph anyways, it's more efficient to just consider each edge
2833        # once.
2834        for fromDecision in self:
2835            fromThere = self[fromDecision]
2836            for toDecision in fromThere:
2837                for transition in fromThere[toDecision]:
2838                    sourceIn = fromDecision in interior
2839                    destIn = toDecision in interior
2840                    if sourceIn and not destIn:
2841                        results[0].add((fromDecision, transition))
2842                    elif destIn and not sourceIn:
2843                        results[1].add((fromDecision, transition))
2844
2845        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:
2847    def replaceZonesInHierarchy(
2848        self,
2849        target: base.AnyDecisionSpecifier,
2850        zone: base.Zone,
2851        level: int
2852    ) -> None:
2853        """
2854        This method replaces one or more zones which contain the
2855        specified `target` decision with a specific zone, at a specific
2856        level in the zone hierarchy (see `zoneHierarchyLevel`). If the
2857        named zone doesn't yet exist, it will be created.
2858
2859        To do this, it looks at all zones which contain the target
2860        decision directly or indirectly (see `zoneAncestors`) and which
2861        are at the specified level.
2862
2863        - Any direct children of those zones which are ancestors of the
2864            target decision are removed from those zones and placed into
2865            the new zone instead, regardless of their levels. Indirect
2866            children are not affected (except perhaps indirectly via
2867            their parents' ancestors changing).
2868        - The new zone is placed into every direct parent of those
2869            zones, regardless of their levels (those parents are by
2870            definition all ancestors of the target decision).
2871        - If there were no zones at the target level, every zone at the
2872            next level down which is an ancestor of the target decision
2873            (or just that decision if the level is 0) is placed into the
2874            new zone as a direct child (and is removed from any previous
2875            parents it had). In this case, the new zone will also be
2876            added as a sub-zone to every ancestor of the target decision
2877            at the level above the specified level, if there are any.
2878            * In this case, if there are no zones at the level below the
2879                specified level, the highest level of zones smaller than
2880                that is treated as the level below, down to targeting
2881                the decision itself.
2882            * Similarly, if there are no zones at the level above the
2883                specified level but there are zones at a higher level,
2884                the new zone will be added to each of the zones in the
2885                lowest level above the target level that has zones in it.
2886
2887        A `MissingDecisionError` will be raised if the specified
2888        decision is not valid, or if the decision is left as default but
2889        there is no current decision in the exploration.
2890
2891        An `InvalidLevelError` will be raised if the level is less than
2892        zero.
2893
2894        Example:
2895
2896        >>> g = DecisionGraph()
2897        >>> g.addDecision('decision')
2898        0
2899        >>> g.addDecision('alternate')
2900        1
2901        >>> g.createZone('zone0', 0)
2902        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
2903 annotations=[])
2904        >>> g.createZone('zone1', 1)
2905        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
2906 annotations=[])
2907        >>> g.createZone('zone2.1', 2)
2908        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2909 annotations=[])
2910        >>> g.createZone('zone2.2', 2)
2911        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
2912 annotations=[])
2913        >>> g.createZone('zone3', 3)
2914        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
2915 annotations=[])
2916        >>> g.addDecisionToZone('decision', 'zone0')
2917        >>> g.addDecisionToZone('alternate', 'zone0')
2918        >>> g.addZoneToZone('zone0', 'zone1')
2919        >>> g.addZoneToZone('zone1', 'zone2.1')
2920        >>> g.addZoneToZone('zone1', 'zone2.2')
2921        >>> g.addZoneToZone('zone2.1', 'zone3')
2922        >>> g.addZoneToZone('zone2.2', 'zone3')
2923        >>> g.zoneHierarchyLevel('zone0')
2924        0
2925        >>> g.zoneHierarchyLevel('zone1')
2926        1
2927        >>> g.zoneHierarchyLevel('zone2.1')
2928        2
2929        >>> g.zoneHierarchyLevel('zone2.2')
2930        2
2931        >>> g.zoneHierarchyLevel('zone3')
2932        3
2933        >>> sorted(g.decisionsInZone('zone0'))
2934        [0, 1]
2935        >>> sorted(g.zoneAncestors('zone0'))
2936        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
2937        >>> g.subZones('zone1')
2938        {'zone0'}
2939        >>> g.zoneParents('zone0')
2940        {'zone1'}
2941        >>> g.replaceZonesInHierarchy('decision', 'new0', 0)
2942        >>> g.zoneParents('zone0')
2943        {'zone1'}
2944        >>> g.zoneParents('new0')
2945        {'zone1'}
2946        >>> sorted(g.zoneAncestors('zone0'))
2947        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
2948        >>> sorted(g.zoneAncestors('new0'))
2949        ['zone1', 'zone2.1', 'zone2.2', 'zone3']
2950        >>> g.decisionsInZone('zone0')
2951        {1}
2952        >>> g.decisionsInZone('new0')
2953        {0}
2954        >>> sorted(g.subZones('zone1'))
2955        ['new0', 'zone0']
2956        >>> g.zoneParents('new0')
2957        {'zone1'}
2958        >>> g.replaceZonesInHierarchy('decision', 'new1', 1)
2959        >>> sorted(g.zoneAncestors(0))
2960        ['new0', 'new1', 'zone2.1', 'zone2.2', 'zone3']
2961        >>> g.subZones('zone1')
2962        {'zone0'}
2963        >>> g.subZones('new1')
2964        {'new0'}
2965        >>> g.zoneParents('new0')
2966        {'new1'}
2967        >>> sorted(g.zoneParents('zone1'))
2968        ['zone2.1', 'zone2.2']
2969        >>> sorted(g.zoneParents('new1'))
2970        ['zone2.1', 'zone2.2']
2971        >>> g.zoneParents('zone2.1')
2972        {'zone3'}
2973        >>> g.zoneParents('zone2.2')
2974        {'zone3'}
2975        >>> sorted(g.subZones('zone2.1'))
2976        ['new1', 'zone1']
2977        >>> sorted(g.subZones('zone2.2'))
2978        ['new1', 'zone1']
2979        >>> sorted(g.allDecisionsInZone('zone2.1'))
2980        [0, 1]
2981        >>> sorted(g.allDecisionsInZone('zone2.2'))
2982        [0, 1]
2983        >>> g.replaceZonesInHierarchy('decision', 'new2', 2)
2984        >>> g.zoneParents('zone2.1')
2985        {'zone3'}
2986        >>> g.zoneParents('zone2.2')
2987        {'zone3'}
2988        >>> g.subZones('zone2.1')
2989        {'zone1'}
2990        >>> g.subZones('zone2.2')
2991        {'zone1'}
2992        >>> g.subZones('new2')
2993        {'new1'}
2994        >>> g.zoneParents('new2')
2995        {'zone3'}
2996        >>> g.allDecisionsInZone('zone2.1')
2997        {1}
2998        >>> g.allDecisionsInZone('zone2.2')
2999        {1}
3000        >>> g.allDecisionsInZone('new2')
3001        {0}
3002        >>> sorted(g.subZones('zone3'))
3003        ['new2', 'zone2.1', 'zone2.2']
3004        >>> g.zoneParents('zone3')
3005        set()
3006        >>> sorted(g.allDecisionsInZone('zone3'))
3007        [0, 1]
3008        >>> g.replaceZonesInHierarchy('decision', 'new3', 3)
3009        >>> sorted(g.subZones('zone3'))
3010        ['zone2.1', 'zone2.2']
3011        >>> g.subZones('new3')
3012        {'new2'}
3013        >>> g.zoneParents('zone3')
3014        set()
3015        >>> g.zoneParents('new3')
3016        set()
3017        >>> g.allDecisionsInZone('zone3')
3018        {1}
3019        >>> g.allDecisionsInZone('new3')
3020        {0}
3021        >>> g.replaceZonesInHierarchy('decision', 'new4', 5)
3022        >>> g.subZones('new4')
3023        {'new3'}
3024        >>> g.zoneHierarchyLevel('new4')
3025        5
3026
3027        Another example of level collapse when trying to replace a zone
3028        at a level above :
3029
3030        >>> g = DecisionGraph()
3031        >>> g.addDecision('A')
3032        0
3033        >>> g.addDecision('B')
3034        1
3035        >>> g.createZone('level0', 0)
3036        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
3037 annotations=[])
3038        >>> g.createZone('level1', 1)
3039        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
3040 annotations=[])
3041        >>> g.createZone('level2', 2)
3042        ZoneInfo(level=2, parents=set(), contents=set(), tags={},\
3043 annotations=[])
3044        >>> g.createZone('level3', 3)
3045        ZoneInfo(level=3, parents=set(), contents=set(), tags={},\
3046 annotations=[])
3047        >>> g.addDecisionToZone('B', 'level0')
3048        >>> g.addZoneToZone('level0', 'level1')
3049        >>> g.addZoneToZone('level1', 'level2')
3050        >>> g.addZoneToZone('level2', 'level3')
3051        >>> g.addDecisionToZone('A', 'level3') # missing some zone levels
3052        >>> g.zoneHierarchyLevel('level3')
3053        3
3054        >>> g.replaceZonesInHierarchy('A', 'newFirst', 1)
3055        >>> g.zoneHierarchyLevel('newFirst')
3056        1
3057        >>> g.decisionsInZone('newFirst')
3058        {0}
3059        >>> g.decisionsInZone('level3')
3060        set()
3061        >>> sorted(g.allDecisionsInZone('level3'))
3062        [0, 1]
3063        >>> g.subZones('newFirst')
3064        set()
3065        >>> sorted(g.subZones('level3'))
3066        ['level2', 'newFirst']
3067        >>> g.zoneParents('newFirst')
3068        {'level3'}
3069        >>> g.replaceZonesInHierarchy('A', 'newSecond', 2)
3070        >>> g.zoneHierarchyLevel('newSecond')
3071        2
3072        >>> g.decisionsInZone('newSecond')
3073        set()
3074        >>> g.allDecisionsInZone('newSecond')
3075        {0}
3076        >>> g.subZones('newSecond')
3077        {'newFirst'}
3078        >>> g.zoneParents('newSecond')
3079        {'level3'}
3080        >>> g.zoneParents('newFirst')
3081        {'newSecond'}
3082        >>> sorted(g.subZones('level3'))
3083        ['level2', 'newSecond']
3084        """
3085        tID = self.resolveDecision(target)
3086
3087        if level < 0:
3088            raise InvalidLevelError(
3089                f"Target level must be positive (got {level})."
3090            )
3091
3092        info = self.getZoneInfo(zone)
3093        if info is None:
3094            info = self.createZone(zone, level)
3095        elif level != info.level:
3096            raise InvalidLevelError(
3097                f"Target level ({level}) does not match the level of"
3098                f" the target zone ({zone!r} at level {info.level})."
3099            )
3100
3101        # Collect both parents & ancestors
3102        parents = self.zoneParents(tID)
3103        ancestors = set(self.zoneAncestors(tID))
3104
3105        # Map from levels to sets of zones from the ancestors pool
3106        levelMap: Dict[int, Set[base.Zone]] = {}
3107        highest = -1
3108        for ancestor in ancestors:
3109            ancestorLevel = self.zoneHierarchyLevel(ancestor)
3110            levelMap.setdefault(ancestorLevel, set()).add(ancestor)
3111            if ancestorLevel > highest:
3112                highest = ancestorLevel
3113
3114        # Figure out if we have target zones to replace or not
3115        reparentDecision = False
3116        if level in levelMap:
3117            # If there are zones at the target level,
3118            targetZones = levelMap[level]
3119
3120            above = set()
3121            below = set()
3122
3123            for replaced in targetZones:
3124                above |= self.zoneParents(replaced)
3125                below |= self.subZones(replaced)
3126                if replaced in parents:
3127                    reparentDecision = True
3128
3129            # Only ancestors should be reparented
3130            below &= ancestors
3131
3132        else:
3133            # Find levels w/ zones in them above + below
3134            levelBelow = level - 1
3135            levelAbove = level + 1
3136            below = levelMap.get(levelBelow, set())
3137            above = levelMap.get(levelAbove, set())
3138
3139            while len(below) == 0 and levelBelow > 0:
3140                levelBelow -= 1
3141                below = levelMap.get(levelBelow, set())
3142
3143            if len(below) == 0:
3144                reparentDecision = True
3145
3146            while len(above) == 0 and levelAbove < highest:
3147                levelAbove += 1
3148                above = levelMap.get(levelAbove, set())
3149
3150        # Handle re-parenting zones below
3151        for under in below:
3152            for parent in self.zoneParents(under):
3153                if parent in ancestors:
3154                    self.removeZoneFromZone(under, parent)
3155            self.addZoneToZone(under, zone)
3156
3157        # Add this zone to each parent
3158        for parent in above:
3159            self.addZoneToZone(zone, parent)
3160
3161        # Re-parent the decision itself if necessary
3162        if reparentDecision:
3163            # (using set() here to avoid size-change-during-iteration)
3164            for parent in set(parents):
3165                self.removeDecisionFromZone(tID, parent)
3166            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]:
3168    def getReciprocal(
3169        self,
3170        decision: base.AnyDecisionSpecifier,
3171        transition: base.Transition
3172    ) -> Optional[base.Transition]:
3173        """
3174        Returns the reciprocal edge for the specified transition from the
3175        specified decision (see `setReciprocal`). Returns
3176        `None` if no reciprocal has been established for that
3177        transition, or if that decision or transition does not exist.
3178        """
3179        dID = self.resolveDecision(decision)
3180
3181        dest = self.getDestination(dID, transition)
3182        if dest is not None:
3183            info = cast(
3184                TransitionProperties,
3185                self.edges[dID, dest, transition]  # type:ignore
3186            )
3187            recip = info.get("reciprocal")
3188            if recip is not None and not isinstance(recip, base.Transition):
3189                raise ValueError(f"Invalid reciprocal value: {repr(recip)}")
3190            return recip
3191        else:
3192            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:
3194    def setReciprocal(
3195        self,
3196        decision: base.AnyDecisionSpecifier,
3197        transition: base.Transition,
3198        reciprocal: Optional[base.Transition],
3199        setBoth: bool = True,
3200        cleanup: bool = True
3201    ) -> None:
3202        """
3203        Sets the 'reciprocal' transition for a particular transition from
3204        a particular decision, and removes the reciprocal property from
3205        any old reciprocal transition.
3206
3207        Raises a `MissingDecisionError` or a `MissingTransitionError` if
3208        the specified decision or transition does not exist.
3209
3210        Raises an `InvalidDestinationError` if the reciprocal transition
3211        does not exist, or if it does exist but does not lead back to
3212        the decision the transition came from.
3213
3214        If `setBoth` is True (the default) then the transition which is
3215        being identified as a reciprocal will also have its reciprocal
3216        property set, pointing back to the primary transition being
3217        modified, and any old reciprocal of that transition will have its
3218        reciprocal set to None. If you want to create a situation with
3219        non-exclusive reciprocals, use `setBoth=False`.
3220
3221        If `cleanup` is True (the default) then abandoned reciprocal
3222        transitions (for both edges if `setBoth` was true) have their
3223        reciprocal properties removed. Set `cleanup` to false if you want
3224        to retain them, although this will result in non-exclusive
3225        reciprocal relationships.
3226
3227        If the `reciprocal` value is None, this deletes the reciprocal
3228        value entirely, and if `setBoth` is true, it does this for the
3229        previous reciprocal edge as well. No error is raised in this case
3230        when there was not already a reciprocal to delete.
3231
3232        Note that one should remove a reciprocal relationship before
3233        redirecting either edge of the pair in a way that gives it a new
3234        reciprocal, since otherwise, a later attempt to remove the
3235        reciprocal with `setBoth` set to True (the default) will end up
3236        deleting the reciprocal information from the other edge that was
3237        already modified. There is no way to reliably detect and avoid
3238        this, because two different decisions could (and often do in
3239        practice) have transitions with identical names, meaning that the
3240        reciprocal value will still be the same, but it will indicate a
3241        different edge in virtue of the destination of the edge changing.
3242
3243        ## Example
3244
3245        >>> g = DecisionGraph()
3246        >>> g.addDecision('G')
3247        0
3248        >>> g.addDecision('H')
3249        1
3250        >>> g.addDecision('I')
3251        2
3252        >>> g.addTransition('G', 'up', 'H', 'down')
3253        >>> g.addTransition('G', 'next', 'H', 'prev')
3254        >>> g.addTransition('H', 'next', 'I', 'prev')
3255        >>> g.addTransition('H', 'return', 'G')
3256        >>> g.setReciprocal('G', 'up', 'next') # Error w/ destinations
3257        Traceback (most recent call last):
3258        ...
3259        exploration.core.InvalidDestinationError...
3260        >>> g.setReciprocal('G', 'up', 'none') # Doesn't exist
3261        Traceback (most recent call last):
3262        ...
3263        exploration.core.MissingTransitionError...
3264        >>> g.getReciprocal('G', 'up')
3265        'down'
3266        >>> g.getReciprocal('H', 'down')
3267        'up'
3268        >>> g.getReciprocal('H', 'return') is None
3269        True
3270        >>> g.setReciprocal('G', 'up', 'return')
3271        >>> g.getReciprocal('G', 'up')
3272        'return'
3273        >>> g.getReciprocal('H', 'down') is None
3274        True
3275        >>> g.getReciprocal('H', 'return')
3276        'up'
3277        >>> g.setReciprocal('H', 'return', None) # remove the reciprocal
3278        >>> g.getReciprocal('G', 'up') is None
3279        True
3280        >>> g.getReciprocal('H', 'down') is None
3281        True
3282        >>> g.getReciprocal('H', 'return') is None
3283        True
3284        >>> g.setReciprocal('G', 'up', 'down', setBoth=False) # one-way
3285        >>> g.getReciprocal('G', 'up')
3286        'down'
3287        >>> g.getReciprocal('H', 'down') is None
3288        True
3289        >>> g.getReciprocal('H', 'return') is None
3290        True
3291        >>> g.setReciprocal('H', 'return', 'up', setBoth=False) # non-sym
3292        >>> g.getReciprocal('G', 'up')
3293        'down'
3294        >>> g.getReciprocal('H', 'down') is None
3295        True
3296        >>> g.getReciprocal('H', 'return')
3297        'up'
3298        >>> g.setReciprocal('H', 'down', 'up') # setBoth not needed
3299        >>> g.getReciprocal('G', 'up')
3300        'down'
3301        >>> g.getReciprocal('H', 'down')
3302        'up'
3303        >>> g.getReciprocal('H', 'return') # unchanged
3304        'up'
3305        >>> g.setReciprocal('G', 'up', 'return', cleanup=False) # no cleanup
3306        >>> g.getReciprocal('G', 'up')
3307        'return'
3308        >>> g.getReciprocal('H', 'down')
3309        'up'
3310        >>> g.getReciprocal('H', 'return') # unchanged
3311        'up'
3312        >>> # Cleanup only applies to reciprocal if setBoth is true
3313        >>> g.setReciprocal('H', 'down', 'up', setBoth=False)
3314        >>> g.getReciprocal('G', 'up')
3315        'return'
3316        >>> g.getReciprocal('H', 'down')
3317        'up'
3318        >>> g.getReciprocal('H', 'return') # not cleaned up w/out setBoth
3319        'up'
3320        >>> g.setReciprocal('H', 'down', 'up') # with cleanup and setBoth
3321        >>> g.getReciprocal('G', 'up')
3322        'down'
3323        >>> g.getReciprocal('H', 'down')
3324        'up'
3325        >>> g.getReciprocal('H', 'return') is None # cleaned up
3326        True
3327        """
3328        dID = self.resolveDecision(decision)
3329
3330        dest = self.destination(dID, transition) # possible KeyError
3331        if reciprocal is None:
3332            rDest = None
3333        else:
3334            rDest = self.getDestination(dest, reciprocal)
3335
3336        # Set or delete reciprocal property
3337        if reciprocal is None:
3338            # Delete the property
3339            info = self.edges[dID, dest, transition]  # type:ignore
3340
3341            old = info.pop('reciprocal')
3342            if setBoth:
3343                rDest = self.getDestination(dest, old)
3344                if rDest != dID:
3345                    raise RuntimeError(
3346                        f"Invalid reciprocal {old!r} for transition"
3347                        f" {transition!r} from {self.identityOf(dID)}:"
3348                        f" destination is {rDest}."
3349                    )
3350                rInfo = self.edges[dest, dID, old]  # type:ignore
3351                if 'reciprocal' in rInfo:
3352                    del rInfo['reciprocal']
3353        else:
3354            # Set the property, checking for errors first
3355            if rDest is None:
3356                raise MissingTransitionError(
3357                    f"Reciprocal transition {reciprocal!r} for"
3358                    f" transition {transition!r} from decision"
3359                    f" {self.identityOf(dID)} does not exist at"
3360                    f" decision {self.identityOf(dest)}"
3361                )
3362
3363            if rDest != dID:
3364                raise InvalidDestinationError(
3365                    f"Reciprocal transition {reciprocal!r} from"
3366                    f" decision {self.identityOf(dest)} does not lead"
3367                    f" back to decision {self.identityOf(dID)}."
3368                )
3369
3370            eProps = self.edges[dID, dest, transition]  # type:ignore [index]
3371            abandoned = eProps.get('reciprocal')
3372            eProps['reciprocal'] = reciprocal
3373            if cleanup and abandoned not in (None, reciprocal):
3374                aProps = self.edges[dest, dID, abandoned]  # type:ignore
3375                if 'reciprocal' in aProps:
3376                    del aProps['reciprocal']
3377
3378            if setBoth:
3379                rProps = self.edges[dest, dID, reciprocal]  # type:ignore
3380                revAbandoned = rProps.get('reciprocal')
3381                rProps['reciprocal'] = transition
3382                # Sever old reciprocal relationship
3383                if cleanup and revAbandoned not in (None, transition):
3384                    raProps = self.edges[
3385                        dID,  # type:ignore
3386                        dest,
3387                        revAbandoned
3388                    ]
3389                    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]]:
3391    def getReciprocalPair(
3392        self,
3393        decision: base.AnyDecisionSpecifier,
3394        transition: base.Transition
3395    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
3396        """
3397        Returns a tuple containing both the destination decision ID and
3398        the transition at that decision which is the reciprocal of the
3399        specified destination & transition. Returns `None` if no
3400        reciprocal has been established for that transition, or if that
3401        decision or transition does not exist.
3402
3403        >>> g = DecisionGraph()
3404        >>> g.addDecision('A')
3405        0
3406        >>> g.addDecision('B')
3407        1
3408        >>> g.addDecision('C')
3409        2
3410        >>> g.addTransition('A', 'up', 'B', 'down')
3411        >>> g.addTransition('B', 'right', 'C', 'left')
3412        >>> g.addTransition('A', 'oneway', 'C')
3413        >>> g.getReciprocalPair('A', 'up')
3414        (1, 'down')
3415        >>> g.getReciprocalPair('B', 'down')
3416        (0, 'up')
3417        >>> g.getReciprocalPair('B', 'right')
3418        (2, 'left')
3419        >>> g.getReciprocalPair('C', 'left')
3420        (1, 'right')
3421        >>> g.getReciprocalPair('C', 'up') is None
3422        True
3423        >>> g.getReciprocalPair('Q', 'up') is None
3424        True
3425        >>> g.getReciprocalPair('A', 'tunnel') is None
3426        True
3427        """
3428        try:
3429            dID = self.resolveDecision(decision)
3430        except MissingDecisionError:
3431            return None
3432
3433        reciprocal = self.getReciprocal(dID, transition)
3434        if reciprocal is None:
3435            return None
3436        else:
3437            destination = self.getDestination(dID, transition)
3438            if destination is None:
3439                return None
3440            else:
3441                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:
3443    def addDecision(
3444        self,
3445        name: base.DecisionName,
3446        domain: Optional[base.Domain] = None,
3447        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3448        annotations: Optional[List[base.Annotation]] = None
3449    ) -> base.DecisionID:
3450        """
3451        Adds a decision to the graph, without any transitions yet. Each
3452        decision will be assigned an ID so name collisions are allowed,
3453        but it's usually best to keep names unique at least within each
3454        zone. If no domain is provided, the `DEFAULT_DOMAIN` will be
3455        used for the decision's domain. A dictionary of tags and/or a
3456        list of annotations (strings in both cases) may be provided.
3457
3458        Returns the newly-assigned `DecisionID` for the decision it
3459        created.
3460
3461        Emits a `DecisionCollisionWarning` if a decision with the
3462        provided name already exists and the `WARN_OF_NAME_COLLISIONS`
3463        global variable is set to `True`.
3464        """
3465        # Defaults
3466        if domain is None:
3467            domain = base.DEFAULT_DOMAIN
3468        if tags is None:
3469            tags = {}
3470        if annotations is None:
3471            annotations = []
3472
3473        # Error checking
3474        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3475            warnings.warn(
3476                (
3477                    f"Adding decision {name!r}: Another decision with"
3478                    f" that name already exists."
3479                ),
3480                DecisionCollisionWarning
3481            )
3482
3483        dID = self._assignID()
3484
3485        # Add the decision
3486        self.add_node(
3487            dID,
3488            name=name,
3489            domain=domain,
3490            tags=tags,
3491            annotations=annotations
3492        )
3493        #TODO: Elide tags/annotations if they're empty?
3494
3495        # Track it in our `nameLookup` dictionary
3496        self.nameLookup.setdefault(name, []).append(dID)
3497
3498        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:
3500    def addIdentifiedDecision(
3501        self,
3502        dID: base.DecisionID,
3503        name: base.DecisionName,
3504        domain: Optional[base.Domain] = None,
3505        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3506        annotations: Optional[List[base.Annotation]] = None
3507    ) -> None:
3508        """
3509        Adds a new decision to the graph using a specific decision ID,
3510        rather than automatically assigning a new decision ID like
3511        `addDecision` does. Otherwise works like `addDecision`.
3512
3513        Raises a `MechanismCollisionError` if the specified decision ID
3514        is already in use.
3515        """
3516        # Defaults
3517        if domain is None:
3518            domain = base.DEFAULT_DOMAIN
3519        if tags is None:
3520            tags = {}
3521        if annotations is None:
3522            annotations = []
3523
3524        # Error checking
3525        if dID in self.nodes:
3526            raise MechanismCollisionError(
3527                f"Cannot add a node with id {dID} and name {name!r}:"
3528                f" that ID is already used by node {self.identityOf(dID)}"
3529            )
3530
3531        if name in self.nameLookup and WARN_OF_NAME_COLLISIONS:
3532            warnings.warn(
3533                (
3534                    f"Adding decision {name!r}: Another decision with"
3535                    f" that name already exists."
3536                ),
3537                DecisionCollisionWarning
3538            )
3539
3540        # Add the decision
3541        self.add_node(
3542            dID,
3543            name=name,
3544            domain=domain,
3545            tags=tags,
3546            annotations=annotations
3547        )
3548        #TODO: Elide tags/annotations if they're empty?
3549
3550        # Track it in our `nameLookup` dictionary
3551        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:
3553    def addTransition(
3554        self,
3555        fromDecision: base.AnyDecisionSpecifier,
3556        name: base.Transition,
3557        toDecision: base.AnyDecisionSpecifier,
3558        reciprocal: Optional[base.Transition] = None,
3559        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
3560        annotations: Optional[List[base.Annotation]] = None,
3561        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
3562        revAnnotations: Optional[List[base.Annotation]] = None,
3563        requires: Optional[base.Requirement] = None,
3564        consequence: Optional[base.Consequence] = None,
3565        revRequires: Optional[base.Requirement] = None,
3566        revConsequece: Optional[base.Consequence] = None
3567    ) -> None:
3568        """
3569        Adds a transition connecting two decisions. A specifier for each
3570        decision is required, as is a name for the transition. If a
3571        `reciprocal` is provided, a reciprocal edge will be added in the
3572        opposite direction using that name; by default only the specified
3573        edge is added. A `TransitionCollisionError` will be raised if the
3574        `reciprocal` matches the name of an existing edge at the
3575        destination decision.
3576
3577        Both decisions must already exist, or a `MissingDecisionError`
3578        will be raised.
3579
3580        A dictionary of tags and/or a list of annotations may be
3581        provided. Tags and/or annotations for the reverse edge may also
3582        be specified if one is being added.
3583
3584        The `requires`, `consequence`, `revRequires`, and `revConsequece`
3585        arguments specify requirements and/or consequences of the new
3586        outgoing and reciprocal edges.
3587        """
3588        # Defaults
3589        if tags is None:
3590            tags = {}
3591        if annotations is None:
3592            annotations = []
3593        if revTags is None:
3594            revTags = {}
3595        if revAnnotations is None:
3596            revAnnotations = []
3597
3598        # Error checking
3599        fromID = self.resolveDecision(fromDecision)
3600        toID = self.resolveDecision(toDecision)
3601
3602        # Note: have to check this first so we don't add the forward edge
3603        # and then error out after a side effect!
3604        if (
3605            reciprocal is not None
3606        and self.getDestination(toDecision, reciprocal) is not None
3607        ):
3608            raise TransitionCollisionError(
3609                f"Cannot add a transition from"
3610                f" {self.identityOf(fromDecision)} to"
3611                f" {self.identityOf(toDecision)} with reciprocal edge"
3612                f" {reciprocal!r}: {reciprocal!r} is already used as an"
3613                f" edge name at {self.identityOf(toDecision)}."
3614            )
3615
3616        # Add the edge
3617        self.add_edge(
3618            fromID,
3619            toID,
3620            key=name,
3621            tags=tags,
3622            annotations=annotations
3623        )
3624        self.setTransitionRequirement(fromDecision, name, requires)
3625        if consequence is not None:
3626            self.setConsequence(fromDecision, name, consequence)
3627        if reciprocal is not None:
3628            # Add the reciprocal edge
3629            self.add_edge(
3630                toID,
3631                fromID,
3632                key=reciprocal,
3633                tags=revTags,
3634                annotations=revAnnotations
3635            )
3636            self.setReciprocal(fromID, name, reciprocal)
3637            self.setTransitionRequirement(
3638                toDecision,
3639                reciprocal,
3640                revRequires
3641            )
3642            if revConsequece is not None:
3643                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]]:
3645    def removeTransition(
3646        self,
3647        fromDecision: base.AnyDecisionSpecifier,
3648        transition: base.Transition,
3649        removeReciprocal=False
3650    ) -> Union[
3651        TransitionProperties,
3652        Tuple[TransitionProperties, TransitionProperties]
3653    ]:
3654        """
3655        Removes a transition. If `removeReciprocal` is true (False is the
3656        default) any reciprocal transition will also be removed (but no
3657        error will occur if there wasn't a reciprocal).
3658
3659        For each removed transition, *every* transition that targeted
3660        that transition as its reciprocal will have its reciprocal set to
3661        `None`, to avoid leaving any invalid reciprocal values.
3662
3663        Raises a `KeyError` if either the target decision or the target
3664        transition does not exist.
3665
3666        Returns a transition properties dictionary with the properties
3667        of the removed transition, or if `removeReciprocal` is true,
3668        returns a pair of such dictionaries for the target transition
3669        and its reciprocal.
3670
3671        ## Example
3672
3673        >>> g = DecisionGraph()
3674        >>> g.addDecision('A')
3675        0
3676        >>> g.addDecision('B')
3677        1
3678        >>> g.addTransition('A', 'up', 'B', 'down', tags={'wide'})
3679        >>> g.addTransition('A', 'in', 'B', 'out') # we won't touch this
3680        >>> g.addTransition('A', 'next', 'B')
3681        >>> g.setReciprocal('A', 'next', 'down', setBoth=False)
3682        >>> p = g.removeTransition('A', 'up')
3683        >>> p['tags']
3684        {'wide'}
3685        >>> g.destinationsFrom('A')
3686        {'in': 1, 'next': 1}
3687        >>> g.destinationsFrom('B')
3688        {'down': 0, 'out': 0}
3689        >>> g.getReciprocal('B', 'down') is None
3690        True
3691        >>> g.getReciprocal('A', 'next') # Asymmetrical left over
3692        'down'
3693        >>> g.getReciprocal('A', 'in') # not affected
3694        'out'
3695        >>> g.getReciprocal('B', 'out') # not affected
3696        'in'
3697        >>> # Now with removeReciprocal set to True
3698        >>> g.addTransition('A', 'up', 'B') # add this back in
3699        >>> g.setReciprocal('A', 'up', 'down') # sets both
3700        >>> p = g.removeTransition('A', 'up', removeReciprocal=True)
3701        >>> g.destinationsFrom('A')
3702        {'in': 1, 'next': 1}
3703        >>> g.destinationsFrom('B')
3704        {'out': 0}
3705        >>> g.getReciprocal('A', 'next') is None
3706        True
3707        >>> g.getReciprocal('A', 'in') # not affected
3708        'out'
3709        >>> g.getReciprocal('B', 'out') # not affected
3710        'in'
3711        >>> g.removeTransition('A', 'none')
3712        Traceback (most recent call last):
3713        ...
3714        exploration.core.MissingTransitionError...
3715        >>> g.removeTransition('Z', 'nope')
3716        Traceback (most recent call last):
3717        ...
3718        exploration.core.MissingDecisionError...
3719        """
3720        # Resolve target ID
3721        fromID = self.resolveDecision(fromDecision)
3722
3723        # raises if either is missing:
3724        destination = self.destination(fromID, transition)
3725        reciprocal = self.getReciprocal(fromID, transition)
3726
3727        # Get dictionaries of parallel & antiparallel edges to be
3728        # checked for invalid reciprocals after removing edges
3729        # Note: these will update live as we remove edges
3730        allAntiparallel = self[destination][fromID]
3731        allParallel = self[fromID][destination]
3732
3733        # Remove the target edge
3734        fProps = self.getTransitionProperties(fromID, transition)
3735        self.remove_edge(fromID, destination, transition)
3736
3737        # Clean up any dangling reciprocal values
3738        for tProps in allAntiparallel.values():
3739            if tProps.get('reciprocal') == transition:
3740                del tProps['reciprocal']
3741
3742        # Remove the reciprocal if requested
3743        if removeReciprocal and reciprocal is not None:
3744            rProps = self.getTransitionProperties(destination, reciprocal)
3745            self.remove_edge(destination, fromID, reciprocal)
3746
3747            # Clean up any dangling reciprocal values
3748            for tProps in allParallel.values():
3749                if tProps.get('reciprocal') == reciprocal:
3750                    del tProps['reciprocal']
3751
3752            return (fProps, rProps)
3753        else:
3754            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:
3756    def addMechanism(
3757        self,
3758        name: base.MechanismName,
3759        where: Optional[base.AnyDecisionSpecifier] = None
3760    ) -> base.MechanismID:
3761        """
3762        Creates a new mechanism with the given name at the specified
3763        decision, returning its assigned ID. If `where` is `None`, it
3764        creates a global mechanism. Raises a `MechanismCollisionError`
3765        if a mechanism with the same name already exists at a specified
3766        decision (or already exists as a global mechanism).
3767
3768        Note that if the decision is deleted, the mechanism will be as
3769        well.
3770
3771        Since `MechanismState`s are not tracked by `DecisionGraph`s but
3772        instead are part of a `State`, the mechanism won't be in any
3773        particular state, which means it will be treated as being in the
3774        `base.DEFAULT_MECHANISM_STATE`.
3775        """
3776        if where is None:
3777            mechs = self.globalMechanisms
3778            dID = None
3779        else:
3780            dID = self.resolveDecision(where)
3781            mechs = self.nodes[dID].setdefault('mechanisms', {})
3782
3783        if name in mechs:
3784            if dID is None:
3785                raise MechanismCollisionError(
3786                    f"A global mechanism named {name!r} already exists."
3787                )
3788            else:
3789                raise MechanismCollisionError(
3790                    f"A mechanism named {name!r} already exists at"
3791                    f" decision {self.identityOf(dID)}."
3792                )
3793
3794        mID = self._assignMechanismID()
3795        mechs[name] = mID
3796        self.mechanisms[mID] = (dID, name)
3797        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]:
3799    def mechanismsAt(
3800        self,
3801        decision: base.AnyDecisionSpecifier
3802    ) -> Dict[base.MechanismName, base.MechanismID]:
3803        """
3804        Returns a dictionary mapping mechanism names to their IDs for
3805        all mechanisms at the specified decision.
3806        """
3807        dID = self.resolveDecision(decision)
3808
3809        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]]:
3811    def mechanismDetails(
3812        self,
3813        mID: base.MechanismID
3814    ) -> Optional[Tuple[Optional[base.DecisionID], base.MechanismName]]:
3815        """
3816        Returns a tuple containing the decision ID and mechanism name
3817        for the specified mechanism. Returns `None` if there is no
3818        mechanism with that ID. For global mechanisms, `None` is used in
3819        place of a decision ID.
3820        """
3821        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:
3823    def deleteMechanism(self, mID: base.MechanismID) -> None:
3824        """
3825        Deletes the specified mechanism.
3826        """
3827        name, dID = self.mechanisms.pop(mID)
3828
3829        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]:
3831    def localLookup(
3832        self,
3833        startFrom: Union[
3834            base.AnyDecisionSpecifier,
3835            Collection[base.AnyDecisionSpecifier]
3836        ],
3837        findAmong: Callable[
3838            ['DecisionGraph', Union[Set[base.DecisionID], str]],
3839            Optional[LookupResult]
3840        ],
3841        fallbackLayerName: Optional[str] = "fallback",
3842        fallbackToAllDecisions: bool = True
3843    ) -> Optional[LookupResult]:
3844        """
3845        Looks up some kind of result in the graph by starting from a
3846        base set of decisions and widening the search iteratively based
3847        on zones. This first searches for result(s) in the set of
3848        decisions given, then in the set of all decisions which are in
3849        level-0 zones containing those decisions, then in level-1 zones,
3850        etc. When it runs out of relevant zones, it will check all
3851        decisions which are in any domain that a decision from the
3852        initial search set is in, and then if `fallbackLayerName` is a
3853        string, it will provide that string instead of a set of decision
3854        IDs to the `findAmong` function as the next layer to search.
3855        After the `fallbackLayerName` is used, if
3856        `fallbackToAllDecisions` is `True` (the default) a final search
3857        will be run on all decisions in the graph. The provided
3858        `findAmong` function is called on each successive decision ID
3859        set, until it generates a non-`None` result. We stop and return
3860        that non-`None` result as soon as one is generated. But if none
3861        of the decision sets consulted generate non-`None` results, then
3862        the entire result will be `None`.
3863        """
3864        # Normalize starting decisions to a set
3865        if isinstance(startFrom, (int, str, base.DecisionSpecifier)):
3866            startFrom = set([startFrom])
3867
3868        # Resolve decision IDs; convert to list
3869        searchArea: Union[Set[base.DecisionID], str] = set(
3870            self.resolveDecision(spec) for spec in startFrom
3871        )
3872
3873        # Find all ancestor zones & all relevant domains
3874        allAncestors = set()
3875        relevantDomains = set()
3876        for startingDecision in searchArea:
3877            allAncestors |= self.zoneAncestors(startingDecision)
3878            relevantDomains.add(self.domainFor(startingDecision))
3879
3880        # Build layers dictionary
3881        ancestorLayers: Dict[int, Set[base.Zone]] = {}
3882        for zone in allAncestors:
3883            info = self.getZoneInfo(zone)
3884            assert info is not None
3885            level = info.level
3886            ancestorLayers.setdefault(level, set()).add(zone)
3887
3888        searchLayers: LookupLayersList = (
3889            cast(LookupLayersList, [None])
3890          + cast(LookupLayersList, sorted(ancestorLayers.keys()))
3891          + cast(LookupLayersList, ["domains"])
3892        )
3893        if fallbackLayerName is not None:
3894            searchLayers.append("fallback")
3895
3896        if fallbackToAllDecisions:
3897            searchLayers.append("all")
3898
3899        # Continue our search through zone layers
3900        for layer in searchLayers:
3901            # Update search area on subsequent iterations
3902            if layer == "domains":
3903                searchArea = set()
3904                for relevant in relevantDomains:
3905                    searchArea |= self.allDecisionsInDomain(relevant)
3906            elif layer == "fallback":
3907                assert fallbackLayerName is not None
3908                searchArea = fallbackLayerName
3909            elif layer == "all":
3910                searchArea = set(self.nodes)
3911            elif layer is not None:
3912                layer = cast(int, layer)  # must be an integer
3913                searchZones = ancestorLayers[layer]
3914                searchArea = set()
3915                for zone in searchZones:
3916                    searchArea |= self.allDecisionsInZone(zone)
3917            # else it's the first iteration and we use the starting
3918            # searchArea
3919
3920            searchResult: Optional[LookupResult] = findAmong(
3921                self,
3922                searchArea
3923            )
3924
3925            if searchResult is not None:
3926                return searchResult
3927
3928        # Didn't find any non-None results.
3929        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]]:
3931    @staticmethod
3932    def uniqueMechanismFinder(name: base.MechanismName) -> Callable[
3933        ['DecisionGraph', Union[Set[base.DecisionID], str]],
3934        Optional[base.MechanismID]
3935    ]:
3936        """
3937        Returns a search function that looks for the given mechanism ID,
3938        suitable for use with `localLookup`. The finder will raise a
3939        `MechanismCollisionError` if it finds more than one mechanism
3940        with the specified name at the same level of the search.
3941        """
3942        def namedMechanismFinder(
3943            graph: 'DecisionGraph',
3944            searchIn: Union[Set[base.DecisionID], str]
3945        ) -> Optional[base.MechanismID]:
3946            """
3947            Generated finder function for `localLookup` to find a unique
3948            mechanism by name.
3949            """
3950            candidates: List[base.DecisionID] = []
3951
3952            if searchIn == "fallback":
3953                if name in graph.globalMechanisms:
3954                    candidates = [graph.globalMechanisms[name]]
3955
3956            else:
3957                assert isinstance(searchIn, set)
3958                for dID in searchIn:
3959                    mechs = graph.nodes[dID].get('mechanisms', {})
3960                    if name in mechs:
3961                        candidates.append(mechs[name])
3962
3963            if len(candidates) > 1:
3964                raise MechanismCollisionError(
3965                    f"There are {len(candidates)} mechanisms named {name!r}"
3966                    f" in the search area ({len(searchIn)} decisions(s))."
3967                )
3968            elif len(candidates) == 1:
3969                return candidates[0]
3970            else:
3971                return None
3972
3973        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:
3975    def lookupMechanism(
3976        self,
3977        startFrom: Union[
3978            base.AnyDecisionSpecifier,
3979            Collection[base.AnyDecisionSpecifier]
3980        ],
3981        name: base.MechanismName
3982    ) -> base.MechanismID:
3983        """
3984        Looks up the mechanism with the given name 'closest' to the
3985        given decision or set of decisions. First it looks for a
3986        mechanism with that name that's at one of those decisions. Then
3987        it starts looking in level-0 zones which contain any of them,
3988        then in level-1 zones, and so on. If it finds two mechanisms
3989        with the target name during the same search pass, it raises a
3990        `MechanismCollisionError`, but if it finds one it returns it.
3991        Raises a `MissingMechanismError` if there is no mechanisms with
3992        that name among global mechanisms (searched after the last
3993        applicable level of zones) or anywhere in the graph (which is the
3994        final level of search after checking global mechanisms).
3995
3996        For example:
3997
3998        >>> d = DecisionGraph()
3999        >>> d.addDecision('A')
4000        0
4001        >>> d.addDecision('B')
4002        1
4003        >>> d.addDecision('C')
4004        2
4005        >>> d.addDecision('D')
4006        3
4007        >>> d.addDecision('E')
4008        4
4009        >>> d.addMechanism('switch', 'A')
4010        0
4011        >>> d.addMechanism('switch', 'B')
4012        1
4013        >>> d.addMechanism('switch', 'C')
4014        2
4015        >>> d.addMechanism('lever', 'D')
4016        3
4017        >>> d.addMechanism('lever', None)  # global
4018        4
4019        >>> d.createZone('Z1', 0)
4020        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4021 annotations=[])
4022        >>> d.createZone('Z2', 0)
4023        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
4024 annotations=[])
4025        >>> d.createZone('Zup', 1)
4026        ZoneInfo(level=1, parents=set(), contents=set(), tags={},\
4027 annotations=[])
4028        >>> d.addDecisionToZone('A', 'Z1')
4029        >>> d.addDecisionToZone('B', 'Z1')
4030        >>> d.addDecisionToZone('C', 'Z2')
4031        >>> d.addDecisionToZone('D', 'Z2')
4032        >>> d.addDecisionToZone('E', 'Z1')
4033        >>> d.addZoneToZone('Z1', 'Zup')
4034        >>> d.addZoneToZone('Z2', 'Zup')
4035        >>> d.lookupMechanism(set(), 'switch')  # 3x among all decisions
4036        Traceback (most recent call last):
4037        ...
4038        exploration.core.MechanismCollisionError...
4039        >>> d.lookupMechanism(set(), 'lever')  # 1x global > 1x all
4040        4
4041        >>> d.lookupMechanism({'D'}, 'lever')  # local
4042        3
4043        >>> d.lookupMechanism({'A'}, 'lever')  # found at D via Zup
4044        3
4045        >>> d.lookupMechanism({'A', 'D'}, 'lever')  # local again
4046        3
4047        >>> d.lookupMechanism({'A'}, 'switch')  # local
4048        0
4049        >>> d.lookupMechanism({'B'}, 'switch')  # local
4050        1
4051        >>> d.lookupMechanism({'C'}, 'switch')  # local
4052        2
4053        >>> d.lookupMechanism({'A', 'B'}, 'switch')  # ambiguous
4054        Traceback (most recent call last):
4055        ...
4056        exploration.core.MechanismCollisionError...
4057        >>> d.lookupMechanism({'A', 'B', 'C'}, 'switch')  # ambiguous
4058        Traceback (most recent call last):
4059        ...
4060        exploration.core.MechanismCollisionError...
4061        >>> d.lookupMechanism({'B', 'D'}, 'switch')  # not ambiguous
4062        1
4063        >>> d.lookupMechanism({'E', 'D'}, 'switch')  # ambiguous at L0 zone
4064        Traceback (most recent call last):
4065        ...
4066        exploration.core.MechanismCollisionError...
4067        >>> d.lookupMechanism({'E'}, 'switch')  # ambiguous at L0 zone
4068        Traceback (most recent call last):
4069        ...
4070        exploration.core.MechanismCollisionError...
4071        >>> d.lookupMechanism({'D'}, 'switch')  # found at L0 zone
4072        2
4073        """
4074        result = self.localLookup(
4075            startFrom,
4076            DecisionGraph.uniqueMechanismFinder(name)
4077        )
4078        if result is None:
4079            raise MissingMechanismError(
4080                f"No mechanism named {name!r}"
4081            )
4082        else:
4083            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:
4085    def resolveMechanism(
4086        self,
4087        specifier: base.AnyMechanismSpecifier,
4088        startFrom: Union[
4089            None,
4090            base.AnyDecisionSpecifier,
4091            Collection[base.AnyDecisionSpecifier]
4092        ] = None
4093    ) -> base.MechanismID:
4094        """
4095        Works like `lookupMechanism`, except it accepts a
4096        `base.AnyMechanismSpecifier` which may have position information
4097        baked in, and so the `startFrom` information is optional. If
4098        position information isn't specified in the mechanism specifier
4099        and startFrom is not provided, the mechanism is searched for at
4100        the global scope and then in the entire graph. On the other
4101        hand, if the specifier includes any position information, the
4102        startFrom value provided here will be ignored.
4103        """
4104        if isinstance(specifier, base.MechanismID):
4105            return specifier
4106
4107        elif isinstance(specifier, base.MechanismName):
4108            if startFrom is None:
4109                startFrom = set()
4110            return self.lookupMechanism(startFrom, specifier)
4111
4112        elif isinstance(specifier, tuple) and len(specifier) == 4:
4113            domain, zone, decision, mechanism = specifier
4114            if domain is None and zone is None and decision is None:
4115                if startFrom is None:
4116                    startFrom = set()
4117                return self.lookupMechanism(startFrom, mechanism)
4118
4119            elif decision is not None:
4120                startFrom = {
4121                    self.resolveDecision(
4122                        base.DecisionSpecifier(domain, zone, decision)
4123                    )
4124                }
4125                return self.lookupMechanism(startFrom, mechanism)
4126
4127            else:  # decision is None but domain and/or zone aren't
4128                startFrom = set()
4129                if zone is not None:
4130                    baseStart = self.allDecisionsInZone(zone)
4131                else:
4132                    baseStart = set(self)
4133
4134                if domain is None:
4135                    startFrom = baseStart
4136                else:
4137                    for dID in baseStart:
4138                        if self.domainFor(dID) == domain:
4139                            startFrom.add(dID)
4140                return self.lookupMechanism(startFrom, mechanism)
4141
4142        else:
4143            raise TypeError(
4144                f"Invalid mechanism specifier: {repr(specifier)}"
4145                f"\n(Must be a mechanism ID, mechanism name, or"
4146                f" mechanism specifier tuple)"
4147            )

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]:
4149    def walkConsequenceMechanisms(
4150        self,
4151        consequence: base.Consequence,
4152        searchFrom: Set[base.DecisionID]
4153    ) -> Generator[base.MechanismID, None, None]:
4154        """
4155        Yields each requirement in the given `base.Consequence`,
4156        including those in `base.Condition`s, `base.ConditionalSkill`s
4157        within `base.Challenge`s, and those set or toggled by
4158        `base.Effect`s. The `searchFrom` argument specifies where to
4159        start searching for mechanisms, since requirements include them
4160        by name, not by ID.
4161        """
4162        for part in base.walkParts(consequence):
4163            if isinstance(part, dict):
4164                if 'skills' in part:  # a Challenge
4165                    for cSkill in part['skills'].walk():
4166                        if isinstance(cSkill, base.ConditionalSkill):
4167                            yield from self.walkRequirementMechanisms(
4168                                cSkill.requirement,
4169                                searchFrom
4170                            )
4171                elif 'condition' in part:  # a Condition
4172                    yield from self.walkRequirementMechanisms(
4173                        part['condition'],
4174                        searchFrom
4175                    )
4176                elif 'value' in part:  # an Effect
4177                    val = part['value']
4178                    if part['type'] == 'set':
4179                        if (
4180                            isinstance(val, tuple)
4181                        and len(val) == 2
4182                        and isinstance(val[1], base.State)
4183                        ):
4184                            yield from self.walkRequirementMechanisms(
4185                                base.ReqMechanism(val[0], val[1]),
4186                                searchFrom
4187                            )
4188                    elif part['type'] == 'toggle':
4189                        if isinstance(val, tuple):
4190                            assert len(val) == 2
4191                            yield from self.walkRequirementMechanisms(
4192                                base.ReqMechanism(val[0], '_'),
4193                                  # state part is ignored here
4194                                searchFrom
4195                            )

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]:
4197    def walkRequirementMechanisms(
4198        self,
4199        req: base.Requirement,
4200        searchFrom: Set[base.DecisionID]
4201    ) -> Generator[base.MechanismID, None, None]:
4202        """
4203        Given a requirement, yields any mechanisms mentioned in that
4204        requirement, in depth-first traversal order.
4205        """
4206        for part in req.walk():
4207            if isinstance(part, base.ReqMechanism):
4208                mech = part.mechanism
4209                yield self.resolveMechanism(
4210                    mech,
4211                    startFrom=searchFrom
4212                )

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: Union[str, type[exploration.base.DefaultZone], NoneType] = 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:
4214    def addUnexploredEdge(
4215        self,
4216        fromDecision: base.AnyDecisionSpecifier,
4217        name: base.Transition,
4218        destinationName: Optional[base.DecisionName] = None,
4219        reciprocal: Optional[base.Transition] = 'return',
4220        toDomain: Optional[base.Domain] = None,
4221        placeInZone: Union[base.Zone, type[base.DefaultZone], None] = None,
4222        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
4223        annotations: Optional[List[base.Annotation]] = None,
4224        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
4225        revAnnotations: Optional[List[base.Annotation]] = None,
4226        requires: Optional[base.Requirement] = None,
4227        consequence: Optional[base.Consequence] = None,
4228        revRequires: Optional[base.Requirement] = None,
4229        revConsequece: Optional[base.Consequence] = None
4230    ) -> base.DecisionID:
4231        """
4232        Adds a transition connecting to a new decision named `'_u.-n-'`
4233        where '-n-' is the number of unknown decisions (named or not)
4234        that have ever been created in this graph (or using the
4235        specified destination name if one is provided). This represents
4236        a transition to an unknown destination. The destination node
4237        gets tagged 'unconfirmed'.
4238
4239        This also adds a reciprocal transition in the reverse direction,
4240        unless `reciprocal` is set to `None`. The reciprocal will use
4241        the provided name (default is 'return'). The new decision will
4242        be in the same domain as the decision it's connected to, unless
4243        `toDecision` is specified, in which case it will be in that
4244        domain.
4245
4246        The new decision will not be placed into any zones, unless
4247        `placeInZone` is specified, in which case it will be placed into
4248        that zone. If that zone needs to be created, it will be created
4249        at level 0; in that case that zone will be added to any
4250        grandparent zones of the decision we're branching off of. If
4251        `placeInZone` is set to `base.DefaultZone`, then the new
4252        decision will be placed into each parent zone of the decision
4253        we're branching off of, as long as the new decision is in the
4254        same domain as the decision we're branching from (otherwise only
4255        an explicit `placeInZone` would apply).
4256
4257        The ID of the decision that was created is returned.
4258
4259        A `MissingDecisionError` will be raised if the starting decision
4260        does not exist, a `TransitionCollisionError` will be raised if
4261        it exists but already has a transition with the given name, and a
4262        `DecisionCollisionWarning` will be issued if a decision with the
4263        specified destination name already exists (won't happen when
4264        using an automatic name).
4265
4266        Lists of tags and/or annotations (strings in both cases) may be
4267        provided. These may also be provided for the reciprocal edge.
4268
4269        Similarly, requirements and/or consequences for either edge may
4270        be provided.
4271
4272        ## Example
4273
4274        >>> g = DecisionGraph()
4275        >>> g.addDecision('A')
4276        0
4277        >>> g.addUnexploredEdge('A', 'up')
4278        1
4279        >>> g.nameFor(1)
4280        '_u.0'
4281        >>> g.decisionTags(1)
4282        {'unconfirmed': 1}
4283        >>> g.addUnexploredEdge('A', 'right', 'B')
4284        2
4285        >>> g.nameFor(2)
4286        'B'
4287        >>> g.decisionTags(2)
4288        {'unconfirmed': 1}
4289        >>> g.addUnexploredEdge('A', 'down', None, 'up')
4290        3
4291        >>> g.nameFor(3)
4292        '_u.2'
4293        >>> g.addUnexploredEdge(
4294        ...    '_u.0',
4295        ...    'beyond',
4296        ...    toDomain='otherDomain',
4297        ...    tags={'fast':1},
4298        ...    revTags={'slow':1},
4299        ...    annotations=['comment'],
4300        ...    revAnnotations=['one', 'two'],
4301        ...    requires=base.ReqCapability('dash'),
4302        ...    revRequires=base.ReqCapability('super dash'),
4303        ...    consequence=[base.effect(gain='super dash')],
4304        ...    revConsequece=[base.effect(lose='super dash')]
4305        ... )
4306        4
4307        >>> g.nameFor(4)
4308        '_u.3'
4309        >>> g.domainFor(4)
4310        'otherDomain'
4311        >>> g.transitionTags('_u.0', 'beyond')
4312        {'fast': 1}
4313        >>> g.transitionAnnotations('_u.0', 'beyond')
4314        ['comment']
4315        >>> g.getTransitionRequirement('_u.0', 'beyond')
4316        ReqCapability('dash')
4317        >>> e = g.getConsequence('_u.0', 'beyond')
4318        >>> e == [base.effect(gain='super dash')]
4319        True
4320        >>> g.transitionTags('_u.3', 'return')
4321        {'slow': 1}
4322        >>> g.transitionAnnotations('_u.3', 'return')
4323        ['one', 'two']
4324        >>> g.getTransitionRequirement('_u.3', 'return')
4325        ReqCapability('super dash')
4326        >>> e = g.getConsequence('_u.3', 'return')
4327        >>> e == [base.effect(lose='super dash')]
4328        True
4329        """
4330        # Defaults
4331        if tags is None:
4332            tags = {}
4333        if annotations is None:
4334            annotations = []
4335        if revTags is None:
4336            revTags = {}
4337        if revAnnotations is None:
4338            revAnnotations = []
4339
4340        # Resolve ID
4341        fromID = self.resolveDecision(fromDecision)
4342        if toDomain is None:
4343            toDomain = self.domainFor(fromID)
4344
4345        if name in self.destinationsFrom(fromID):
4346            raise TransitionCollisionError(
4347                f"Cannot add a new edge {name!r}:"
4348                f" {self.identityOf(fromDecision)} already has an"
4349                f" outgoing edge with that name."
4350            )
4351
4352        if destinationName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
4353            warnings.warn(
4354                (
4355                    f"Cannot add a new unexplored node"
4356                    f" {destinationName!r}: A decision with that name"
4357                    f" already exists.\n(Leave destinationName as None"
4358                    f" to use an automatic name.)"
4359                ),
4360                DecisionCollisionWarning
4361            )
4362
4363        # Create the new unexplored decision and add the edge
4364        if destinationName is None:
4365            toName = '_u.' + str(self.unknownCount)
4366        else:
4367            toName = destinationName
4368        self.unknownCount += 1
4369        newID = self.addDecision(toName, domain=toDomain)
4370        self.addTransition(
4371            fromID,
4372            name,
4373            newID,
4374            tags=tags,
4375            annotations=annotations
4376        )
4377        self.setTransitionRequirement(fromID, name, requires)
4378        if consequence is not None:
4379            self.setConsequence(fromID, name, consequence)
4380
4381        # Add it to a zone if requested
4382        if (
4383            placeInZone is base.DefaultZone
4384        and toDomain == self.domainFor(fromID)
4385        ):
4386            # Add to each parent of the from decision
4387            for parent in self.zoneParents(fromID):
4388                self.addDecisionToZone(newID, parent)
4389        elif placeInZone is not None:
4390            # Otherwise add it to one specific zone, creating that zone
4391            # at level 0 if necessary
4392            assert isinstance(placeInZone, base.Zone)
4393            if self.getZoneInfo(placeInZone) is None:
4394                self.createZone(placeInZone, 0)
4395                # Add new zone to each grandparent of the from decision
4396                for parent in self.zoneParents(fromID):
4397                    for grandparent in self.zoneParents(parent):
4398                        self.addZoneToZone(placeInZone, grandparent)
4399            self.addDecisionToZone(newID, placeInZone)
4400
4401        # Create the reciprocal edge
4402        if reciprocal is not None:
4403            self.addTransition(
4404                newID,
4405                reciprocal,
4406                fromID,
4407                tags=revTags,
4408                annotations=revAnnotations
4409            )
4410            self.setTransitionRequirement(newID, reciprocal, revRequires)
4411            if revConsequece is not None:
4412                self.setConsequence(newID, reciprocal, revConsequece)
4413            # Set as a reciprocal
4414            self.setReciprocal(fromID, name, reciprocal)
4415
4416        # Tag the destination as 'unconfirmed'
4417        self.tagDecision(newID, 'unconfirmed')
4418
4419        # Return ID of new destination
4420        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]:
4422    def retargetTransition(
4423        self,
4424        fromDecision: base.AnyDecisionSpecifier,
4425        transition: base.Transition,
4426        newDestination: base.AnyDecisionSpecifier,
4427        swapReciprocal=True,
4428        errorOnNameColision=True
4429    ) -> Optional[base.Transition]:
4430        """
4431        Given a particular decision and a transition at that decision,
4432        changes that transition so that it goes to the specified new
4433        destination instead of wherever it was connected to before. If
4434        the new destination is the same as the old one, no changes are
4435        made.
4436
4437        If `swapReciprocal` is set to True (the default) then any
4438        reciprocal edge at the old destination will be deleted, and a
4439        new reciprocal edge from the new destination with equivalent
4440        properties to the original reciprocal will be created, pointing
4441        to the origin of the specified transition. If `swapReciprocal`
4442        is set to False, then the reciprocal relationship with any old
4443        reciprocal edge will be removed, but the old reciprocal edge
4444        will not be changed.
4445
4446        Note that if `errorOnNameColision` is True (the default), then
4447        if the reciprocal transition has the same name as a transition
4448        which already exists at the new destination node, a
4449        `TransitionCollisionError` will be thrown. However, if it is set
4450        to False, the reciprocal transition will be renamed with a suffix
4451        to avoid any possible name collisions. Either way, the name of
4452        the reciprocal transition (possibly just changed) will be
4453        returned, or None if there was no reciprocal transition.
4454
4455        ## Example
4456
4457        >>> g = DecisionGraph()
4458        >>> for fr, to, nm in [
4459        ...     ('A', 'B', 'up'),
4460        ...     ('A', 'B', 'up2'),
4461        ...     ('B', 'A', 'down'),
4462        ...     ('B', 'B', 'self'),
4463        ...     ('B', 'C', 'next'),
4464        ...     ('C', 'B', 'prev')
4465        ... ]:
4466        ...     if g.getDecision(fr) is None:
4467        ...        g.addDecision(fr)
4468        ...     if g.getDecision(to) is None:
4469        ...         g.addDecision(to)
4470        ...     g.addTransition(fr, nm, to)
4471        0
4472        1
4473        2
4474        >>> g.setReciprocal('A', 'up', 'down')
4475        >>> g.setReciprocal('B', 'next', 'prev')
4476        >>> g.destination('A', 'up')
4477        1
4478        >>> g.destination('B', 'down')
4479        0
4480        >>> g.retargetTransition('A', 'up', 'C')
4481        'down'
4482        >>> g.destination('A', 'up')
4483        2
4484        >>> g.getDestination('B', 'down') is None
4485        True
4486        >>> g.destination('C', 'down')
4487        0
4488        >>> g.addTransition('A', 'next', 'B')
4489        >>> g.addTransition('B', 'prev', 'A')
4490        >>> g.setReciprocal('A', 'next', 'prev')
4491        >>> # Can't swap a reciprocal in a way that would collide names
4492        >>> g.getReciprocal('C', 'prev')
4493        'next'
4494        >>> g.retargetTransition('C', 'prev', 'A')
4495        Traceback (most recent call last):
4496        ...
4497        exploration.core.TransitionCollisionError...
4498        >>> g.retargetTransition('C', 'prev', 'A', swapReciprocal=False)
4499        'next'
4500        >>> g.destination('C', 'prev')
4501        0
4502        >>> g.destination('A', 'next') # not changed
4503        1
4504        >>> # Reciprocal relationship is severed:
4505        >>> g.getReciprocal('C', 'prev') is None
4506        True
4507        >>> g.getReciprocal('B', 'next') is None
4508        True
4509        >>> # Swap back so we can do another demo
4510        >>> g.retargetTransition('C', 'prev', 'B', swapReciprocal=False)
4511        >>> # Note return value was None here because there was no reciprocal
4512        >>> g.setReciprocal('C', 'prev', 'next')
4513        >>> # Swap reciprocal by renaming it
4514        >>> g.retargetTransition('C', 'prev', 'A', errorOnNameColision=False)
4515        'next.1'
4516        >>> g.getReciprocal('C', 'prev')
4517        'next.1'
4518        >>> g.destination('C', 'prev')
4519        0
4520        >>> g.destination('A', 'next.1')
4521        2
4522        >>> g.destination('A', 'next')
4523        1
4524        >>> # Note names are the same but these are from different nodes
4525        >>> g.getReciprocal('A', 'next')
4526        'prev'
4527        >>> g.getReciprocal('A', 'next.1')
4528        'prev'
4529        """
4530        fromID = self.resolveDecision(fromDecision)
4531        newDestID = self.resolveDecision(newDestination)
4532
4533        # Figure out the old destination of the transition we're swapping
4534        oldDestID = self.destination(fromID, transition)
4535        reciprocal = self.getReciprocal(fromID, transition)
4536
4537        # If thew new destination is the same, we don't do anything!
4538        if oldDestID == newDestID:
4539            return reciprocal
4540
4541        # First figure out reciprocal business so we can error out
4542        # without making changes if we need to
4543        if swapReciprocal and reciprocal is not None:
4544            reciprocal = self.rebaseTransition(
4545                oldDestID,
4546                reciprocal,
4547                newDestID,
4548                swapReciprocal=False,
4549                errorOnNameColision=errorOnNameColision
4550            )
4551
4552        # Handle the forward transition...
4553        # Find the transition properties
4554        tProps = self.getTransitionProperties(fromID, transition)
4555
4556        # Delete the edge
4557        self.removeEdgeByKey(fromID, transition)
4558
4559        # Add the new edge
4560        self.addTransition(fromID, transition, newDestID)
4561
4562        # Reapply the transition properties
4563        self.setTransitionProperties(fromID, transition, **tProps)
4564
4565        # Handle the reciprocal transition if there is one...
4566        if reciprocal is not None:
4567            if not swapReciprocal:
4568                # Then sever the relationship, but only if that edge
4569                # still exists (we might be in the middle of a rebase)
4570                check = self.getDestination(oldDestID, reciprocal)
4571                if check is not None:
4572                    self.setReciprocal(
4573                        oldDestID,
4574                        reciprocal,
4575                        None,
4576                        setBoth=False # Other transition was deleted already
4577                    )
4578            else:
4579                # Establish new reciprocal relationship
4580                self.setReciprocal(
4581                    fromID,
4582                    transition,
4583                    reciprocal
4584                )
4585
4586        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:
4588    def rebaseTransition(
4589        self,
4590        fromDecision: base.AnyDecisionSpecifier,
4591        transition: base.Transition,
4592        newBase: base.AnyDecisionSpecifier,
4593        swapReciprocal=True,
4594        errorOnNameColision=True
4595    ) -> base.Transition:
4596        """
4597        Given a particular destination and a transition at that
4598        destination, changes that transition's origin to a new base
4599        decision. If the new source is the same as the old one, no
4600        changes are made.
4601
4602        If `swapReciprocal` is set to True (the default) then any
4603        reciprocal edge at the destination will be retargeted to point
4604        to the new source so that it can remain a reciprocal. If
4605        `swapReciprocal` is set to False, then the reciprocal
4606        relationship with any old reciprocal edge will be removed, but
4607        the old reciprocal edge will not be otherwise changed.
4608
4609        Note that if `errorOnNameColision` is True (the default), then
4610        if the transition has the same name as a transition which
4611        already exists at the new source node, a
4612        `TransitionCollisionError` will be raised. However, if it is set
4613        to False, the transition will be renamed with a suffix to avoid
4614        any possible name collisions. Either way, the (possibly new) name
4615        of the transition that was rebased will be returned.
4616
4617        ## Example
4618
4619        >>> g = DecisionGraph()
4620        >>> for fr, to, nm in [
4621        ...     ('A', 'B', 'up'),
4622        ...     ('A', 'B', 'up2'),
4623        ...     ('B', 'A', 'down'),
4624        ...     ('B', 'B', 'self'),
4625        ...     ('B', 'C', 'next'),
4626        ...     ('C', 'B', 'prev')
4627        ... ]:
4628        ...     if g.getDecision(fr) is None:
4629        ...        g.addDecision(fr)
4630        ...     if g.getDecision(to) is None:
4631        ...         g.addDecision(to)
4632        ...     g.addTransition(fr, nm, to)
4633        0
4634        1
4635        2
4636        >>> g.setReciprocal('A', 'up', 'down')
4637        >>> g.setReciprocal('B', 'next', 'prev')
4638        >>> g.destination('A', 'up')
4639        1
4640        >>> g.destination('B', 'down')
4641        0
4642        >>> g.rebaseTransition('B', 'down', 'C')
4643        'down'
4644        >>> g.destination('A', 'up')
4645        2
4646        >>> g.getDestination('B', 'down') is None
4647        True
4648        >>> g.destination('C', 'down')
4649        0
4650        >>> g.addTransition('A', 'next', 'B')
4651        >>> g.addTransition('B', 'prev', 'A')
4652        >>> g.setReciprocal('A', 'next', 'prev')
4653        >>> # Can't rebase in a way that would collide names
4654        >>> g.rebaseTransition('B', 'next', 'A')
4655        Traceback (most recent call last):
4656        ...
4657        exploration.core.TransitionCollisionError...
4658        >>> g.rebaseTransition('B', 'next', 'A', errorOnNameColision=False)
4659        'next.1'
4660        >>> g.destination('C', 'prev')
4661        0
4662        >>> g.destination('A', 'next') # not changed
4663        1
4664        >>> # Collision is avoided by renaming
4665        >>> g.destination('A', 'next.1')
4666        2
4667        >>> # Swap without reciprocal
4668        >>> g.getReciprocal('A', 'next.1')
4669        'prev'
4670        >>> g.getReciprocal('C', 'prev')
4671        'next.1'
4672        >>> g.rebaseTransition('A', 'next.1', 'B', swapReciprocal=False)
4673        'next.1'
4674        >>> g.getReciprocal('C', 'prev') is None
4675        True
4676        >>> g.destination('C', 'prev')
4677        0
4678        >>> g.getDestination('A', 'next.1') is None
4679        True
4680        >>> g.destination('A', 'next')
4681        1
4682        >>> g.destination('B', 'next.1')
4683        2
4684        >>> g.getReciprocal('B', 'next.1') is None
4685        True
4686        >>> # Rebase in a way that creates a self-edge
4687        >>> g.rebaseTransition('A', 'next', 'B')
4688        'next'
4689        >>> g.getDestination('A', 'next') is None
4690        True
4691        >>> g.destination('B', 'next')
4692        1
4693        >>> g.destination('B', 'prev') # swapped as a reciprocal
4694        1
4695        >>> g.getReciprocal('B', 'next') # still reciprocals
4696        'prev'
4697        >>> g.getReciprocal('B', 'prev')
4698        'next'
4699        >>> # And rebasing of a self-edge also works
4700        >>> g.rebaseTransition('B', 'prev', 'A')
4701        'prev'
4702        >>> g.destination('A', 'prev')
4703        1
4704        >>> g.destination('B', 'next')
4705        0
4706        >>> g.getReciprocal('B', 'next') # still reciprocals
4707        'prev'
4708        >>> g.getReciprocal('A', 'prev')
4709        'next'
4710        >>> # We've effectively reversed this edge/reciprocal pair
4711        >>> # by rebasing twice
4712        """
4713        fromID = self.resolveDecision(fromDecision)
4714        newBaseID = self.resolveDecision(newBase)
4715
4716        # If thew new base is the same, we don't do anything!
4717        if newBaseID == fromID:
4718            return transition
4719
4720        # First figure out reciprocal business so we can swap it later
4721        # without making changes if we need to
4722        destination = self.destination(fromID, transition)
4723        reciprocal = self.getReciprocal(fromID, transition)
4724        # Check for an already-deleted reciprocal
4725        if (
4726            reciprocal is not None
4727        and self.getDestination(destination, reciprocal) is None
4728        ):
4729            reciprocal = None
4730
4731        # Handle the base swap...
4732        # Find the transition properties
4733        tProps = self.getTransitionProperties(fromID, transition)
4734
4735        # Check for a collision
4736        targetDestinations = self.destinationsFrom(newBaseID)
4737        if transition in targetDestinations:
4738            if errorOnNameColision:
4739                raise TransitionCollisionError(
4740                    f"Cannot rebase transition {transition!r} from"
4741                    f" {self.identityOf(fromDecision)}: it would be a"
4742                    f" duplicate transition name at the new base"
4743                    f" decision {self.identityOf(newBase)}."
4744                )
4745            else:
4746                # Figure out a good fresh name
4747                newName = utils.uniqueName(
4748                    transition,
4749                    targetDestinations
4750                )
4751        else:
4752            newName = transition
4753
4754        # Delete the edge
4755        self.removeEdgeByKey(fromID, transition)
4756
4757        # Add the new edge
4758        self.addTransition(newBaseID, newName, destination)
4759
4760        # Reapply the transition properties
4761        self.setTransitionProperties(newBaseID, newName, **tProps)
4762
4763        # Handle the reciprocal transition if there is one...
4764        if reciprocal is not None:
4765            if not swapReciprocal:
4766                # Then sever the relationship
4767                self.setReciprocal(
4768                    destination,
4769                    reciprocal,
4770                    None,
4771                    setBoth=False # Other transition was deleted already
4772                )
4773            else:
4774                # Otherwise swap the reciprocal edge
4775                self.retargetTransition(
4776                    destination,
4777                    reciprocal,
4778                    newBaseID,
4779                    swapReciprocal=False
4780                )
4781
4782                # And establish a new reciprocal relationship
4783                self.setReciprocal(
4784                    newBaseID,
4785                    newName,
4786                    reciprocal
4787                )
4788
4789        # Return the new name in case it was changed
4790        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]:
4796    def mergeDecisions(
4797        self,
4798        merge: base.AnyDecisionSpecifier,
4799        mergeInto: base.AnyDecisionSpecifier,
4800        errorOnNameColision=True
4801    ) -> Dict[base.Transition, base.Transition]:
4802        """
4803        Merges two decisions, deleting the first after transferring all
4804        of its incoming and outgoing edges to target the second one,
4805        whose name is retained. The second decision will be added to any
4806        zones that the first decision was a member of. If either decision
4807        does not exist, a `MissingDecisionError` will be raised. If
4808        `merge` and `mergeInto` are the same, then nothing will be
4809        changed.
4810
4811        Unless `errorOnNameColision` is set to False, a
4812        `TransitionCollisionError` will be raised if the two decisions
4813        have outgoing transitions with the same name. If
4814        `errorOnNameColision` is set to False, then such edges will be
4815        renamed using a suffix to avoid name collisions, with edges
4816        connected to the second decision retaining their original names
4817        and edges that were connected to the first decision getting
4818        renamed.
4819
4820        Any mechanisms located at the first decision will be moved to the
4821        merged decision.
4822
4823        The tags and annotations of the merged decision are added to the
4824        tags and annotations of the merge target. If there are shared
4825        tags, the values from the merge target will override those of
4826        the merged decision. If this is undesired behavior, clear/edit
4827        the tags/annotations of the merged decision before the merge.
4828
4829        The 'unconfirmed' tag is treated specially: if both decisions have
4830        it it will be retained, but otherwise it will be dropped even if
4831        one of the situations had it before.
4832
4833        The domain of the second decision is retained.
4834
4835        Returns a dictionary mapping each original transition name to
4836        its new name in cases where transitions get renamed; this will
4837        be empty when no re-naming occurs, including when
4838        `errorOnNameColision` is True. If there were any transitions
4839        connecting the nodes that were merged, these become self-edges
4840        of the merged node (and may be renamed if necessary).
4841        Note that all renamed transitions were originally based on the
4842        first (merged) node, since transitions of the second (merge
4843        target) node are not renamed.
4844
4845        ## Example
4846
4847        >>> g = DecisionGraph()
4848        >>> for fr, to, nm in [
4849        ...     ('A', 'B', 'up'),
4850        ...     ('A', 'B', 'up2'),
4851        ...     ('B', 'A', 'down'),
4852        ...     ('B', 'B', 'self'),
4853        ...     ('B', 'C', 'next'),
4854        ...     ('C', 'B', 'prev'),
4855        ...     ('A', 'C', 'right')
4856        ... ]:
4857        ...     if g.getDecision(fr) is None:
4858        ...        g.addDecision(fr)
4859        ...     if g.getDecision(to) is None:
4860        ...         g.addDecision(to)
4861        ...     g.addTransition(fr, nm, to)
4862        0
4863        1
4864        2
4865        >>> g.getDestination('A', 'up')
4866        1
4867        >>> g.getDestination('B', 'down')
4868        0
4869        >>> sorted(g)
4870        [0, 1, 2]
4871        >>> g.setReciprocal('A', 'up', 'down')
4872        >>> g.setReciprocal('B', 'next', 'prev')
4873        >>> g.mergeDecisions('C', 'B')
4874        {}
4875        >>> g.destinationsFrom('A')
4876        {'up': 1, 'up2': 1, 'right': 1}
4877        >>> g.destinationsFrom('B')
4878        {'down': 0, 'self': 1, 'prev': 1, 'next': 1}
4879        >>> 'C' in g
4880        False
4881        >>> g.mergeDecisions('A', 'A') # does nothing
4882        {}
4883        >>> # Can't merge non-existent decision
4884        >>> g.mergeDecisions('A', 'Z')
4885        Traceback (most recent call last):
4886        ...
4887        exploration.core.MissingDecisionError...
4888        >>> g.mergeDecisions('Z', 'A')
4889        Traceback (most recent call last):
4890        ...
4891        exploration.core.MissingDecisionError...
4892        >>> # Can't merge decisions w/ shared edge names
4893        >>> g.addDecision('D')
4894        3
4895        >>> g.addTransition('D', 'next', 'A')
4896        >>> g.addTransition('A', 'prev', 'D')
4897        >>> g.setReciprocal('D', 'next', 'prev')
4898        >>> g.mergeDecisions('D', 'B') # both have a 'next' transition
4899        Traceback (most recent call last):
4900        ...
4901        exploration.core.TransitionCollisionError...
4902        >>> # Auto-rename colliding edges
4903        >>> g.mergeDecisions('D', 'B', errorOnNameColision=False)
4904        {'next': 'next.1'}
4905        >>> g.destination('B', 'next') # merge target unchanged
4906        1
4907        >>> g.destination('B', 'next.1') # merged decision name changed
4908        0
4909        >>> g.destination('B', 'prev') # name unchanged (no collision)
4910        1
4911        >>> g.getReciprocal('B', 'next') # unchanged (from B)
4912        'prev'
4913        >>> g.getReciprocal('B', 'next.1') # from A
4914        'prev'
4915        >>> g.getReciprocal('A', 'prev') # from B
4916        'next.1'
4917
4918        ## Folding four nodes into a 2-node loop
4919
4920        >>> g = DecisionGraph()
4921        >>> g.addDecision('X')
4922        0
4923        >>> g.addDecision('Y')
4924        1
4925        >>> g.addTransition('X', 'next', 'Y', 'prev')
4926        >>> g.addDecision('preX')
4927        2
4928        >>> g.addDecision('postY')
4929        3
4930        >>> g.addTransition('preX', 'next', 'X', 'prev')
4931        >>> g.addTransition('Y', 'next', 'postY', 'prev')
4932        >>> g.mergeDecisions('preX', 'Y', errorOnNameColision=False)
4933        {'next': 'next.1'}
4934        >>> g.destinationsFrom('X')
4935        {'next': 1, 'prev': 1}
4936        >>> g.destinationsFrom('Y')
4937        {'prev': 0, 'next': 3, 'next.1': 0}
4938        >>> 2 in g
4939        False
4940        >>> g.destinationsFrom('postY')
4941        {'prev': 1}
4942        >>> g.mergeDecisions('postY', 'X', errorOnNameColision=False)
4943        {'prev': 'prev.1'}
4944        >>> g.destinationsFrom('X')
4945        {'next': 1, 'prev': 1, 'prev.1': 1}
4946        >>> g.destinationsFrom('Y') # order 'cause of 'next' re-target
4947        {'prev': 0, 'next.1': 0, 'next': 0}
4948        >>> 2 in g
4949        False
4950        >>> 3 in g
4951        False
4952        >>> # Reciprocals are tangled...
4953        >>> g.getReciprocal(0, 'prev')
4954        'next.1'
4955        >>> g.getReciprocal(0, 'prev.1')
4956        'next'
4957        >>> g.getReciprocal(1, 'next')
4958        'prev.1'
4959        >>> g.getReciprocal(1, 'next.1')
4960        'prev'
4961        >>> # Note: one merge cannot handle both extra transitions
4962        >>> # because their reciprocals are crossed (e.g., prev.1 <-> next)
4963        >>> # (It would merge both edges but the result would retain
4964        >>> # 'next.1' instead of retaining 'next'.)
4965        >>> g.mergeTransitions('X', 'prev.1', 'prev', mergeReciprocal=False)
4966        >>> g.mergeTransitions('Y', 'next.1', 'next', mergeReciprocal=True)
4967        >>> g.destinationsFrom('X')
4968        {'next': 1, 'prev': 1}
4969        >>> g.destinationsFrom('Y')
4970        {'prev': 0, 'next': 0}
4971        >>> # Reciprocals were salvaged in second merger
4972        >>> g.getReciprocal('X', 'prev')
4973        'next'
4974        >>> g.getReciprocal('Y', 'next')
4975        'prev'
4976
4977        ## Merging with tags/requirements/annotations/consequences
4978
4979        >>> g = DecisionGraph()
4980        >>> g.addDecision('X')
4981        0
4982        >>> g.addDecision('Y')
4983        1
4984        >>> g.addDecision('Z')
4985        2
4986        >>> g.addTransition('X', 'next', 'Y', 'prev')
4987        >>> g.addTransition('X', 'down', 'Z', 'up')
4988        >>> g.tagDecision('X', 'tag0', 1)
4989        >>> g.tagDecision('Y', 'tag1', 10)
4990        >>> g.tagDecision('Y', 'unconfirmed')
4991        >>> g.tagDecision('Z', 'tag1', 20)
4992        >>> g.tagDecision('Z', 'tag2', 30)
4993        >>> g.tagTransition('X', 'next', 'ttag1', 11)
4994        >>> g.tagTransition('Y', 'prev', 'ttag2', 22)
4995        >>> g.tagTransition('X', 'down', 'ttag3', 33)
4996        >>> g.tagTransition('Z', 'up', 'ttag4', 44)
4997        >>> g.annotateDecision('Y', 'annotation 1')
4998        >>> g.annotateDecision('Z', 'annotation 2')
4999        >>> g.annotateDecision('Z', 'annotation 3')
5000        >>> g.annotateTransition('Y', 'prev', 'trans annotation 1')
5001        >>> g.annotateTransition('Y', 'prev', 'trans annotation 2')
5002        >>> g.annotateTransition('Z', 'up', 'trans annotation 3')
5003        >>> g.setTransitionRequirement(
5004        ...     'X',
5005        ...     'next',
5006        ...     base.ReqCapability('power')
5007        ... )
5008        >>> g.setTransitionRequirement(
5009        ...     'Y',
5010        ...     'prev',
5011        ...     base.ReqTokens('token', 1)
5012        ... )
5013        >>> g.setTransitionRequirement(
5014        ...     'X',
5015        ...     'down',
5016        ...     base.ReqCapability('power2')
5017        ... )
5018        >>> g.setTransitionRequirement(
5019        ...     'Z',
5020        ...     'up',
5021        ...     base.ReqTokens('token2', 2)
5022        ... )
5023        >>> g.setConsequence(
5024        ...     'Y',
5025        ...     'prev',
5026        ...     [base.effect(gain="power2")]
5027        ... )
5028        >>> g.mergeDecisions('Y', 'Z')
5029        {}
5030        >>> g.destination('X', 'next')
5031        2
5032        >>> g.destination('X', 'down')
5033        2
5034        >>> g.destination('Z', 'prev')
5035        0
5036        >>> g.destination('Z', 'up')
5037        0
5038        >>> g.decisionTags('X')
5039        {'tag0': 1}
5040        >>> g.decisionTags('Z')  # note that 'unconfirmed' is removed
5041        {'tag1': 20, 'tag2': 30}
5042        >>> g.transitionTags('X', 'next')
5043        {'ttag1': 11}
5044        >>> g.transitionTags('X', 'down')
5045        {'ttag3': 33}
5046        >>> g.transitionTags('Z', 'prev')
5047        {'ttag2': 22}
5048        >>> g.transitionTags('Z', 'up')
5049        {'ttag4': 44}
5050        >>> g.decisionAnnotations('Z')
5051        ['annotation 2', 'annotation 3', 'annotation 1']
5052        >>> g.transitionAnnotations('Z', 'prev')
5053        ['trans annotation 1', 'trans annotation 2']
5054        >>> g.transitionAnnotations('Z', 'up')
5055        ['trans annotation 3']
5056        >>> g.getTransitionRequirement('X', 'next')
5057        ReqCapability('power')
5058        >>> g.getTransitionRequirement('Z', 'prev')
5059        ReqTokens('token', 1)
5060        >>> g.getTransitionRequirement('X', 'down')
5061        ReqCapability('power2')
5062        >>> g.getTransitionRequirement('Z', 'up')
5063        ReqTokens('token2', 2)
5064        >>> g.getConsequence('Z', 'prev') == [
5065        ...     {
5066        ...         'type': 'gain',
5067        ...         'applyTo': 'active',
5068        ...         'value': 'power2',
5069        ...         'charges': None,
5070        ...         'delay': None,
5071        ...         'hidden': False
5072        ...     }
5073        ... ]
5074        True
5075
5076        ## Merging into node without tags
5077
5078        >>> g = DecisionGraph()
5079        >>> g.addDecision('X')
5080        0
5081        >>> g.addDecision('Y')
5082        1
5083        >>> g.tagDecision('Y', 'unconfirmed')  # special handling
5084        >>> g.tagDecision('Y', 'tag', 'value')
5085        >>> g.mergeDecisions('Y', 'X')
5086        {}
5087        >>> g.decisionTags('X')
5088        {'tag': 'value'}
5089        >>> 0 in g  # Second argument remains
5090        True
5091        >>> 1 in g  # First argument is deleted
5092        False
5093        """
5094        # Resolve IDs
5095        mergeID = self.resolveDecision(merge)
5096        mergeIntoID = self.resolveDecision(mergeInto)
5097
5098        # Create our result as an empty dictionary
5099        result: Dict[base.Transition, base.Transition] = {}
5100
5101        # Short-circuit if the two decisions are the same
5102        if mergeID == mergeIntoID:
5103            return result
5104
5105        # MissingDecisionErrors from here if either doesn't exist
5106        allNewOutgoing = set(self.destinationsFrom(mergeID))
5107        allOldOutgoing = set(self.destinationsFrom(mergeIntoID))
5108        # Find colliding transition names
5109        collisions = allNewOutgoing & allOldOutgoing
5110        if len(collisions) > 0 and errorOnNameColision:
5111            raise TransitionCollisionError(
5112                f"Cannot merge decision {self.identityOf(merge)} into"
5113                f" decision {self.identityOf(mergeInto)}: the decisions"
5114                f" share {len(collisions)} transition names:"
5115                f" {collisions}\n(Note that errorOnNameColision was set"
5116                f" to True, set it to False to allow the operation by"
5117                f" renaming half of those transitions.)"
5118            )
5119
5120        # Record zones that will have to change after the merge
5121        zoneParents = self.zoneParents(mergeID)
5122
5123        # First, swap all incoming edges, along with their reciprocals
5124        # This will include self-edges, which will be retargeted and
5125        # whose reciprocals will be rebased in the process, leading to
5126        # the possibility of a missing edge during the loop
5127        for source, incoming in self.allEdgesTo(mergeID):
5128            # Skip this edge if it was already swapped away because it's
5129            # a self-loop with a reciprocal whose reciprocal was
5130            # processed earlier in the loop
5131            if incoming not in self.destinationsFrom(source):
5132                continue
5133
5134            # Find corresponding outgoing edge
5135            outgoing = self.getReciprocal(source, incoming)
5136
5137            # Swap both edges to new destination
5138            newOutgoing = self.retargetTransition(
5139                source,
5140                incoming,
5141                mergeIntoID,
5142                swapReciprocal=True,
5143                errorOnNameColision=False # collisions were detected above
5144            )
5145            # Add to our result if the name of the reciprocal was
5146            # changed
5147            if (
5148                outgoing is not None
5149            and newOutgoing is not None
5150            and outgoing != newOutgoing
5151            ):
5152                result[outgoing] = newOutgoing
5153
5154        # Next, swap any remaining outgoing edges (which didn't have
5155        # reciprocals, or they'd already be swapped, unless they were
5156        # self-edges previously). Note that in this loop, there can't be
5157        # any self-edges remaining, although there might be connections
5158        # between the merging nodes that need to become self-edges
5159        # because they used to be a self-edge that was half-retargeted
5160        # by the previous loop.
5161        # Note: a copy is used here to avoid iterating over a changing
5162        # dictionary
5163        for stillOutgoing in copy.copy(self.destinationsFrom(mergeID)):
5164            newOutgoing = self.rebaseTransition(
5165                mergeID,
5166                stillOutgoing,
5167                mergeIntoID,
5168                swapReciprocal=True,
5169                errorOnNameColision=False # collisions were detected above
5170            )
5171            if stillOutgoing != newOutgoing:
5172                result[stillOutgoing] = newOutgoing
5173
5174        # At this point, there shouldn't be any remaining incoming or
5175        # outgoing edges!
5176        assert self.degree(mergeID) == 0
5177
5178        # Merge tags & annotations
5179        # Note that these operations affect the underlying graph
5180        destTags = self.decisionTags(mergeIntoID)
5181        destUnvisited = 'unconfirmed' in destTags
5182        sourceTags = self.decisionTags(mergeID)
5183        sourceUnvisited = 'unconfirmed' in sourceTags
5184        # Copy over only new tags, leaving existing tags alone
5185        for key in sourceTags:
5186            if key not in destTags:
5187                destTags[key] = sourceTags[key]
5188
5189        if int(destUnvisited) + int(sourceUnvisited) == 1:
5190            del destTags['unconfirmed']
5191
5192        self.decisionAnnotations(mergeIntoID).extend(
5193            self.decisionAnnotations(mergeID)
5194        )
5195
5196        # Transfer zones
5197        for zone in zoneParents:
5198            self.addDecisionToZone(mergeIntoID, zone)
5199
5200        # Delete the old node
5201        self.removeDecision(mergeID)
5202
5203        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:
5205    def removeDecision(self, decision: base.AnyDecisionSpecifier) -> None:
5206        """
5207        Deletes the specified decision from the graph, updating
5208        attendant structures like zones. Note that the ID of the deleted
5209        node will NOT be reused, unless it's specifically provided to
5210        `addIdentifiedDecision`.
5211
5212        For example:
5213
5214        >>> dg = DecisionGraph()
5215        >>> dg.addDecision('A')
5216        0
5217        >>> dg.addDecision('B')
5218        1
5219        >>> list(dg)
5220        [0, 1]
5221        >>> 1 in dg
5222        True
5223        >>> 'B' in dg.nameLookup
5224        True
5225        >>> dg.removeDecision('B')
5226        >>> 1 in dg
5227        False
5228        >>> list(dg)
5229        [0]
5230        >>> 'B' in dg.nameLookup
5231        False
5232        >>> dg.addDecision('C')  # doesn't re-use ID
5233        2
5234        """
5235        dID = self.resolveDecision(decision)
5236
5237        # Remove the target from all zones:
5238        for zone in self.zones:
5239            self.removeDecisionFromZone(dID, zone)
5240
5241        # Remove the node but record the current name
5242        name = self.nodes[dID]['name']
5243        self.remove_node(dID)
5244
5245        # Clean up the nameLookup entry
5246        luInfo = self.nameLookup[name]
5247        luInfo.remove(dID)
5248        if len(luInfo) == 0:
5249            self.nameLookup.pop(name)
5250
5251        # 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):
5253    def renameDecision(
5254        self,
5255        decision: base.AnyDecisionSpecifier,
5256        newName: base.DecisionName
5257    ):
5258        """
5259        Renames a decision. The decision retains its old ID.
5260
5261        Generates a `DecisionCollisionWarning` if a decision using the new
5262        name already exists and `WARN_OF_NAME_COLLISIONS` is enabled.
5263
5264        Example:
5265
5266        >>> g = DecisionGraph()
5267        >>> g.addDecision('one')
5268        0
5269        >>> g.addDecision('three')
5270        1
5271        >>> g.addTransition('one', '>', 'three')
5272        >>> g.addTransition('three', '<', 'one')
5273        >>> g.tagDecision('three', 'hi')
5274        >>> g.annotateDecision('three', 'note')
5275        >>> g.destination('one', '>')
5276        1
5277        >>> g.destination('three', '<')
5278        0
5279        >>> g.renameDecision('three', 'two')
5280        >>> g.resolveDecision('one')
5281        0
5282        >>> g.resolveDecision('two')
5283        1
5284        >>> g.resolveDecision('three')
5285        Traceback (most recent call last):
5286        ...
5287        exploration.core.MissingDecisionError...
5288        >>> g.destination('one', '>')
5289        1
5290        >>> g.nameFor(1)
5291        'two'
5292        >>> g.getDecision('three') is None
5293        True
5294        >>> g.destination('two', '<')
5295        0
5296        >>> g.decisionTags('two')
5297        {'hi': 1}
5298        >>> g.decisionAnnotations('two')
5299        ['note']
5300        """
5301        dID = self.resolveDecision(decision)
5302
5303        if newName in self.nameLookup and WARN_OF_NAME_COLLISIONS:
5304            warnings.warn(
5305                (
5306                    f"Can't rename {self.identityOf(decision)} as"
5307                    f" {newName!r} because a decision with that name"
5308                    f" already exists."
5309                ),
5310                DecisionCollisionWarning
5311            )
5312
5313        # Update name in node
5314        oldName = self.nodes[dID]['name']
5315        self.nodes[dID]['name'] = newName
5316
5317        # Update nameLookup entries
5318        oldNL = self.nameLookup[oldName]
5319        oldNL.remove(dID)
5320        if len(oldNL) == 0:
5321            self.nameLookup.pop(oldName)
5322        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:
5324    def mergeTransitions(
5325        self,
5326        fromDecision: base.AnyDecisionSpecifier,
5327        merge: base.Transition,
5328        mergeInto: base.Transition,
5329        mergeReciprocal=True
5330    ) -> None:
5331        """
5332        Given a decision and two transitions that start at that decision,
5333        merges the first transition into the second transition, combining
5334        their transition properties (using `mergeProperties`) and
5335        deleting the first transition. By default any reciprocal of the
5336        first transition is also merged into the reciprocal of the
5337        second, although you can set `mergeReciprocal` to `False` to
5338        disable this in which case the old reciprocal will lose its
5339        reciprocal relationship, even if the transition that was merged
5340        into does not have a reciprocal.
5341
5342        If the two names provided are the same, nothing will happen.
5343
5344        If the two transitions do not share the same destination, they
5345        cannot be merged, and an `InvalidDestinationError` will result.
5346        Use `retargetTransition` beforehand to ensure that they do if you
5347        want to merge transitions with different destinations.
5348
5349        A `MissingDecisionError` or `MissingTransitionError` will result
5350        if the decision or either transition does not exist.
5351
5352        If merging reciprocal properties was requested and the first
5353        transition does not have a reciprocal, then no reciprocal
5354        properties change. However, if the second transition does not
5355        have a reciprocal and the first does, the first transition's
5356        reciprocal will be set to the reciprocal of the second
5357        transition, and that transition will not be deleted as usual.
5358
5359        ## Example
5360
5361        >>> g = DecisionGraph()
5362        >>> g.addDecision('A')
5363        0
5364        >>> g.addDecision('B')
5365        1
5366        >>> g.addTransition('A', 'up', 'B')
5367        >>> g.addTransition('B', 'down', 'A')
5368        >>> g.setReciprocal('A', 'up', 'down')
5369        >>> # Merging a transition with no reciprocal
5370        >>> g.addTransition('A', 'up2', 'B')
5371        >>> g.mergeTransitions('A', 'up2', 'up')
5372        >>> g.getDestination('A', 'up2') is None
5373        True
5374        >>> g.getDestination('A', 'up')
5375        1
5376        >>> # Merging a transition with a reciprocal & tags
5377        >>> g.addTransition('A', 'up2', 'B')
5378        >>> g.addTransition('B', 'down2', 'A')
5379        >>> g.setReciprocal('A', 'up2', 'down2')
5380        >>> g.tagTransition('A', 'up2', 'one')
5381        >>> g.tagTransition('B', 'down2', 'two')
5382        >>> g.mergeTransitions('B', 'down2', 'down')
5383        >>> g.getDestination('A', 'up2') is None
5384        True
5385        >>> g.getDestination('A', 'up')
5386        1
5387        >>> g.getDestination('B', 'down2') is None
5388        True
5389        >>> g.getDestination('B', 'down')
5390        0
5391        >>> # Merging requirements uses ReqAll (i.e., 'and' logic)
5392        >>> g.addTransition('A', 'up2', 'B')
5393        >>> g.setTransitionProperties(
5394        ...     'A',
5395        ...     'up2',
5396        ...     requirement=base.ReqCapability('dash')
5397        ... )
5398        >>> g.setTransitionProperties('A', 'up',
5399        ...     requirement=base.ReqCapability('slide'))
5400        >>> g.mergeTransitions('A', 'up2', 'up')
5401        >>> g.getDestination('A', 'up2') is None
5402        True
5403        >>> repr(g.getTransitionRequirement('A', 'up'))
5404        "ReqAll([ReqCapability('dash'), ReqCapability('slide')])"
5405        >>> # Errors if destinations differ, or if something is missing
5406        >>> g.mergeTransitions('A', 'down', 'up')
5407        Traceback (most recent call last):
5408        ...
5409        exploration.core.MissingTransitionError...
5410        >>> g.mergeTransitions('Z', 'one', 'two')
5411        Traceback (most recent call last):
5412        ...
5413        exploration.core.MissingDecisionError...
5414        >>> g.addDecision('C')
5415        2
5416        >>> g.addTransition('A', 'down', 'C')
5417        >>> g.mergeTransitions('A', 'down', 'up')
5418        Traceback (most recent call last):
5419        ...
5420        exploration.core.InvalidDestinationError...
5421        >>> # Merging a reciprocal onto an edge that doesn't have one
5422        >>> g.addTransition('A', 'down2', 'C')
5423        >>> g.addTransition('C', 'up2', 'A')
5424        >>> g.setReciprocal('A', 'down2', 'up2')
5425        >>> g.tagTransition('C', 'up2', 'narrow')
5426        >>> g.getReciprocal('A', 'down') is None
5427        True
5428        >>> g.mergeTransitions('A', 'down2', 'down')
5429        >>> g.getDestination('A', 'down2') is None
5430        True
5431        >>> g.getDestination('A', 'down')
5432        2
5433        >>> g.getDestination('C', 'up2')
5434        0
5435        >>> g.getReciprocal('A', 'down')
5436        'up2'
5437        >>> g.getReciprocal('C', 'up2')
5438        'down'
5439        >>> g.transitionTags('C', 'up2')
5440        {'narrow': 1}
5441        >>> # Merging without a reciprocal
5442        >>> g.addTransition('C', 'up', 'A')
5443        >>> g.mergeTransitions('C', 'up2', 'up', mergeReciprocal=False)
5444        >>> g.getDestination('C', 'up2') is None
5445        True
5446        >>> g.getDestination('C', 'up')
5447        0
5448        >>> g.transitionTags('C', 'up') # tag gets merged
5449        {'narrow': 1}
5450        >>> g.getDestination('A', 'down')
5451        2
5452        >>> g.getReciprocal('A', 'down') is None
5453        True
5454        >>> g.getReciprocal('C', 'up') is None
5455        True
5456        >>> # Merging w/ normal reciprocals
5457        >>> g.addDecision('D')
5458        3
5459        >>> g.addDecision('E')
5460        4
5461        >>> g.addTransition('D', 'up', 'E', 'return')
5462        >>> g.addTransition('E', 'down', 'D')
5463        >>> g.mergeTransitions('E', 'return', 'down')
5464        >>> g.getDestination('D', 'up')
5465        4
5466        >>> g.getDestination('E', 'down')
5467        3
5468        >>> g.getDestination('E', 'return') is None
5469        True
5470        >>> g.getReciprocal('D', 'up')
5471        'down'
5472        >>> g.getReciprocal('E', 'down')
5473        'up'
5474        >>> # Merging w/ weird reciprocals
5475        >>> g.addTransition('E', 'return', 'D')
5476        >>> g.setReciprocal('E', 'return', 'up', setBoth=False)
5477        >>> g.getReciprocal('D', 'up')
5478        'down'
5479        >>> g.getReciprocal('E', 'down')
5480        'up'
5481        >>> g.getReciprocal('E', 'return') # shared
5482        'up'
5483        >>> g.mergeTransitions('E', 'return', 'down')
5484        >>> g.getDestination('D', 'up')
5485        4
5486        >>> g.getDestination('E', 'down')
5487        3
5488        >>> g.getDestination('E', 'return') is None
5489        True
5490        >>> g.getReciprocal('D', 'up')
5491        'down'
5492        >>> g.getReciprocal('E', 'down')
5493        'up'
5494        """
5495        fromID = self.resolveDecision(fromDecision)
5496
5497        # Short-circuit in the no-op case
5498        if merge == mergeInto:
5499            return
5500
5501        # These lines will raise a MissingDecisionError or
5502        # MissingTransitionError if needed
5503        dest1 = self.destination(fromID, merge)
5504        dest2 = self.destination(fromID, mergeInto)
5505
5506        if dest1 != dest2:
5507            raise InvalidDestinationError(
5508                f"Cannot merge transition {merge!r} into transition"
5509                f" {mergeInto!r} from decision"
5510                f" {self.identityOf(fromDecision)} because their"
5511                f" destinations are different ({self.identityOf(dest1)}"
5512                f" and {self.identityOf(dest2)}).\nNote: you can use"
5513                f" `retargetTransition` to change the destination of a"
5514                f" transition."
5515            )
5516
5517        # Find and the transition properties
5518        props1 = self.getTransitionProperties(fromID, merge)
5519        props2 = self.getTransitionProperties(fromID, mergeInto)
5520        merged = mergeProperties(props1, props2)
5521        # Note that this doesn't change the reciprocal:
5522        self.setTransitionProperties(fromID, mergeInto, **merged)
5523
5524        # Merge the reciprocal properties if requested
5525        # Get reciprocal to merge into
5526        reciprocal = self.getReciprocal(fromID, mergeInto)
5527        # Get reciprocal that needs cleaning up
5528        altReciprocal = self.getReciprocal(fromID, merge)
5529        # If the reciprocal to be merged actually already was the
5530        # reciprocal to merge into, there's nothing to do here
5531        if altReciprocal != reciprocal:
5532            if not mergeReciprocal:
5533                # In this case, we sever the reciprocal relationship if
5534                # there is a reciprocal
5535                if altReciprocal is not None:
5536                    self.setReciprocal(dest1, altReciprocal, None)
5537                    # By default setBoth takes care of the other half
5538            else:
5539                # In this case, we try to merge reciprocals
5540                # If altReciprocal is None, we don't need to do anything
5541                if altReciprocal is not None:
5542                    # Was there already a reciprocal or not?
5543                    if reciprocal is None:
5544                        # altReciprocal becomes the new reciprocal and is
5545                        # not deleted
5546                        self.setReciprocal(
5547                            fromID,
5548                            mergeInto,
5549                            altReciprocal
5550                        )
5551                    else:
5552                        # merge reciprocal properties
5553                        props1 = self.getTransitionProperties(
5554                            dest1,
5555                            altReciprocal
5556                        )
5557                        props2 = self.getTransitionProperties(
5558                            dest2,
5559                            reciprocal
5560                        )
5561                        merged = mergeProperties(props1, props2)
5562                        self.setTransitionProperties(
5563                            dest1,
5564                            reciprocal,
5565                            **merged
5566                        )
5567
5568                        # delete the old reciprocal transition
5569                        self.remove_edge(dest1, fromID, altReciprocal)
5570
5571        # Delete the old transition (reciprocal deletion/severance is
5572        # handled above if necessary)
5573        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:
5575    def isConfirmed(self, decision: base.AnyDecisionSpecifier) -> bool:
5576        """
5577        Returns `True` or `False` depending on whether or not the
5578        specified decision has been confirmed. Uses the presence or
5579        absence of the 'unconfirmed' tag to determine this.
5580
5581        Note: 'unconfirmed' is used instead of 'confirmed' so that large
5582        graphs with many confirmed nodes will be smaller when saved.
5583        """
5584        dID = self.resolveDecision(decision)
5585
5586        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: Union[type[exploration.base.DefaultZone], str, NoneType] = 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]]:
5588    def replaceUnconfirmed(
5589        self,
5590        fromDecision: base.AnyDecisionSpecifier,
5591        transition: base.Transition,
5592        connectTo: Optional[base.AnyDecisionSpecifier] = None,
5593        reciprocal: Optional[base.Transition] = None,
5594        requirement: Optional[base.Requirement] = None,
5595        applyConsequence: Optional[base.Consequence] = None,
5596        placeInZone: Union[type[base.DefaultZone], base.Zone, None] = None,
5597        forceNew: bool = False,
5598        tags: Optional[Dict[base.Tag, base.TagValue]] = None,
5599        annotations: Optional[List[base.Annotation]] = None,
5600        revRequires: Optional[base.Requirement] = None,
5601        revConsequence: Optional[base.Consequence] = None,
5602        revTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5603        revAnnotations: Optional[List[base.Annotation]] = None,
5604        decisionTags: Optional[Dict[base.Tag, base.TagValue]] = None,
5605        decisionAnnotations: Optional[List[base.Annotation]] = None
5606    ) -> Tuple[
5607        Dict[base.Transition, base.Transition],
5608        Dict[base.Transition, base.Transition]
5609    ]:
5610        """
5611        Given a decision and an edge name in that decision, where the
5612        named edge leads to a decision with an unconfirmed exploration
5613        state (see `isConfirmed`), renames the unexplored decision on
5614        the other end of that edge using the given `connectTo` name, or
5615        if a decision using that name already exists, merges the
5616        unexplored decision into that decision. If `connectTo` is a
5617        `DecisionSpecifier` whose target doesn't exist, it will be
5618        treated as just a name, but if it's an ID and it doesn't exist,
5619        you'll get a `MissingDecisionError`. If a `reciprocal` is provided,
5620        a reciprocal edge will be added using that name connecting the
5621        `connectTo` decision back to the original decision. If this
5622        transition already exists, it must also point to a node which is
5623        also unexplored, and which will also be merged into the
5624        `fromDecision` node.
5625
5626        If `connectTo` is not given (or is set to `None` explicitly)
5627        then the name of the unexplored decision will not be changed,
5628        unless that name has the form `'_u.-n-'` where `-n-` is a positive
5629        integer (i.e., the form given to automatically-named unknown
5630        nodes). In that case, the name will be changed to `'_x.-n-'` using
5631        the same number, or a higher number if that name is already taken.
5632
5633        If the destination is being renamed or if the destination's
5634        exploration state counts as unexplored, the exploration state of
5635        the destination will be set to 'exploring'.
5636
5637        If a `placeInZone` is specified, the destination will be placed
5638        directly into that zone (even if it already existed and has zone
5639        information), and it will be removed from any other zones it had
5640        been a direct member of. If `placeInZone` is set to
5641        `DefaultZone`, then the destination will be placed into each zone
5642        which is a direct parent of the origin, but only if the
5643        destination is not an already-explored existing decision AND
5644        it is not already in any zones (in those cases no zone changes
5645        are made). This will also remove it from any previous zones it
5646        had been a part of. If `placeInZone` is left as `None` (the
5647        default) no zone changes are made.
5648
5649        If `placeInZone` is specified and that zone didn't already exist,
5650        it will be created as a new level-0 zone and will be added as a
5651        sub-zone of each zone that's a direct parent of any level-0 zone
5652        that the origin is a member of.
5653
5654        If `forceNew` is specified, then the destination will just be
5655        renamed, even if another decision with the same name already
5656        exists. It's an error to use `forceNew` with a decision ID as
5657        the destination.
5658
5659        Any additional edges pointing to or from the unknown node(s)
5660        being replaced will also be re-targeted at the now-discovered
5661        known destination(s) if necessary. These edges will retain their
5662        reciprocal names, or if this would cause a name clash, they will
5663        be renamed with a suffix (see `retargetTransition`).
5664
5665        The return value is a pair of dictionaries mapping old names to
5666        new ones that just includes the names which were changed. The
5667        first dictionary contains renamed transitions that are outgoing
5668        from the new destination node (which used to be outgoing from
5669        the unexplored node). The second dictionary contains renamed
5670        transitions that are outgoing from the source node (which used
5671        to be outgoing from the unexplored node attached to the
5672        reciprocal transition; if there was no reciprocal transition
5673        specified then this will always be an empty dictionary).
5674
5675        An `ExplorationStatusError` will be raised if the destination
5676        of the specified transition counts as visited (see
5677        `hasBeenVisited`). An `ExplorationStatusError` will also be
5678        raised if the `connectTo`'s `reciprocal` transition does not lead
5679        to an unconfirmed decision (it's okay if this second transition
5680        doesn't exist). A `TransitionCollisionError` will be raised if
5681        the unconfirmed destination decision already has an outgoing
5682        transition with the specified `reciprocal` which does not lead
5683        back to the `fromDecision`.
5684
5685        The transition properties (requirement, consequences, tags,
5686        and/or annotations) of the replaced transition will be copied
5687        over to the new transition. Transition properties from the
5688        reciprocal transition will also be copied for the newly created
5689        reciprocal edge. Properties for any additional edges to/from the
5690        unknown node will also be copied.
5691
5692        Also, any transition properties on existing forward or reciprocal
5693        edges from the destination node with the indicated reverse name
5694        will be merged with those from the target transition. Note that
5695        this merging process may introduce corruption of complex
5696        transition consequences. TODO: Fix that!
5697
5698        Any tags and annotations are added to copied tags/annotations,
5699        but specified requirements, and/or consequences will replace
5700        previous requirements/consequences, rather than being added to
5701        them.
5702
5703        ## Example
5704
5705        >>> g = DecisionGraph()
5706        >>> g.addDecision('A')
5707        0
5708        >>> g.addUnexploredEdge('A', 'up')
5709        1
5710        >>> g.destination('A', 'up')
5711        1
5712        >>> g.destination('_u.0', 'return')
5713        0
5714        >>> g.replaceUnconfirmed('A', 'up', 'B', 'down')
5715        ({}, {})
5716        >>> g.destination('A', 'up')
5717        1
5718        >>> g.nameFor(1)
5719        'B'
5720        >>> g.destination('B', 'down')
5721        0
5722        >>> g.getDestination('B', 'return') is None
5723        True
5724        >>> '_u.0' in g.nameLookup
5725        False
5726        >>> g.getReciprocal('A', 'up')
5727        'down'
5728        >>> g.getReciprocal('B', 'down')
5729        'up'
5730        >>> # Two unexplored edges to the same node:
5731        >>> g.addDecision('C')
5732        2
5733        >>> g.addTransition('B', 'next', 'C')
5734        >>> g.addTransition('C', 'prev', 'B')
5735        >>> g.setReciprocal('B', 'next', 'prev')
5736        >>> g.addUnexploredEdge('A', 'next', 'D', 'prev')
5737        3
5738        >>> g.addTransition('C', 'down', 'D')
5739        >>> g.addTransition('D', 'up', 'C')
5740        >>> g.setReciprocal('C', 'down', 'up')
5741        >>> g.replaceUnconfirmed('C', 'down')
5742        ({}, {})
5743        >>> g.destination('C', 'down')
5744        3
5745        >>> g.destination('A', 'next')
5746        3
5747        >>> g.destinationsFrom('D')
5748        {'prev': 0, 'up': 2}
5749        >>> g.decisionTags('D')
5750        {}
5751        >>> # An unexplored transition which turns out to connect to a
5752        >>> # known decision, with name collisions
5753        >>> g.addUnexploredEdge('D', 'next', reciprocal='prev')
5754        4
5755        >>> g.tagDecision('_u.2', 'wet')
5756        >>> g.addUnexploredEdge('B', 'next', reciprocal='prev') # edge taken
5757        Traceback (most recent call last):
5758        ...
5759        exploration.core.TransitionCollisionError...
5760        >>> g.addUnexploredEdge('A', 'prev', reciprocal='next')
5761        5
5762        >>> g.tagDecision('_u.3', 'dry')
5763        >>> # Add transitions that will collide when merged
5764        >>> g.addUnexploredEdge('_u.2', 'up') # collides with A/up
5765        6
5766        >>> g.addUnexploredEdge('_u.3', 'prev') # collides with D/prev
5767        7
5768        >>> g.getReciprocal('A', 'prev')
5769        'next'
5770        >>> g.replaceUnconfirmed('A', 'prev', 'D', 'next') # two gone
5771        ({'prev': 'prev.1'}, {'up': 'up.1'})
5772        >>> g.destination('A', 'prev')
5773        3
5774        >>> g.destination('D', 'next')
5775        0
5776        >>> g.getReciprocal('A', 'prev')
5777        'next'
5778        >>> g.getReciprocal('D', 'next')
5779        'prev'
5780        >>> # Note that further unexplored structures are NOT merged
5781        >>> # even if they match against existing structures...
5782        >>> g.destination('A', 'up.1')
5783        6
5784        >>> g.destination('D', 'prev.1')
5785        7
5786        >>> '_u.2' in g.nameLookup
5787        False
5788        >>> '_u.3' in g.nameLookup
5789        False
5790        >>> g.decisionTags('D') # tags are merged
5791        {'dry': 1}
5792        >>> g.decisionTags('A')
5793        {'wet': 1}
5794        >>> # Auto-renaming an anonymous unexplored node
5795        >>> g.addUnexploredEdge('B', 'out')
5796        8
5797        >>> g.replaceUnconfirmed('B', 'out')
5798        ({}, {})
5799        >>> '_u.6' in g
5800        False
5801        >>> g.destination('B', 'out')
5802        8
5803        >>> g.nameFor(8)
5804        '_x.6'
5805        >>> g.destination('_x.6', 'return')
5806        1
5807        >>> # Placing a node into a zone
5808        >>> g.addUnexploredEdge('B', 'through')
5809        9
5810        >>> g.getDecision('E') is None
5811        True
5812        >>> g.replaceUnconfirmed(
5813        ...     'B',
5814        ...     'through',
5815        ...     'E',
5816        ...     'back',
5817        ...     placeInZone='Zone'
5818        ... )
5819        ({}, {})
5820        >>> g.getDecision('E')
5821        9
5822        >>> g.destination('B', 'through')
5823        9
5824        >>> g.destination('E', 'back')
5825        1
5826        >>> g.zoneParents(9)
5827        {'Zone'}
5828        >>> g.addUnexploredEdge('E', 'farther')
5829        10
5830        >>> g.replaceUnconfirmed(
5831        ...     'E',
5832        ...     'farther',
5833        ...     'F',
5834        ...     'closer',
5835        ...     placeInZone=base.DefaultZone
5836        ... )
5837        ({}, {})
5838        >>> g.destination('E', 'farther')
5839        10
5840        >>> g.destination('F', 'closer')
5841        9
5842        >>> g.zoneParents(10)
5843        {'Zone'}
5844        >>> g.addUnexploredEdge('F', 'backwards', placeInZone='Enoz')
5845        11
5846        >>> g.replaceUnconfirmed(
5847        ...     'F',
5848        ...     'backwards',
5849        ...     'G',
5850        ...     'forwards',
5851        ...     placeInZone=base.DefaultZone
5852        ... )
5853        ({}, {})
5854        >>> g.destination('F', 'backwards')
5855        11
5856        >>> g.destination('G', 'forwards')
5857        10
5858        >>> g.zoneParents(11)  # not changed since it already had a zone
5859        {'Enoz'}
5860        >>> # TODO: forceNew example
5861        """
5862
5863        # Defaults
5864        if tags is None:
5865            tags = {}
5866        if annotations is None:
5867            annotations = []
5868        if revTags is None:
5869            revTags = {}
5870        if revAnnotations is None:
5871            revAnnotations = []
5872        if decisionTags is None:
5873            decisionTags = {}
5874        if decisionAnnotations is None:
5875            decisionAnnotations = []
5876
5877        # Resolve source
5878        fromID = self.resolveDecision(fromDecision)
5879
5880        # Figure out destination decision
5881        oldUnexplored = self.destination(fromID, transition)
5882        if self.isConfirmed(oldUnexplored):
5883            raise ExplorationStatusError(
5884                f"Transition {transition!r} from"
5885                f" {self.identityOf(fromDecision)} does not lead to an"
5886                f" unconfirmed decision (it leads to"
5887                f" {self.identityOf(oldUnexplored)} which is not tagged"
5888                f" 'unconfirmed')."
5889            )
5890
5891        # Resolve destination
5892        newName: Optional[base.DecisionName] = None
5893        connectID: Optional[base.DecisionID] = None
5894        if forceNew:
5895            if isinstance(connectTo, base.DecisionID):
5896                raise TypeError(
5897                    f"connectTo cannot be a decision ID when forceNew"
5898                    f" is True. Got: {self.identityOf(connectTo)}"
5899                )
5900            elif isinstance(connectTo, base.DecisionSpecifier):
5901                newName = connectTo.name
5902            elif isinstance(connectTo, base.DecisionName):
5903                newName = connectTo
5904            elif connectTo is None:
5905                oldName = self.nameFor(oldUnexplored)
5906                if (
5907                    oldName.startswith('_u.')
5908                and oldName[3:].isdigit()
5909                ):
5910                    newName = utils.uniqueName('_x.' + oldName[3:], self)
5911                else:
5912                    newName = oldName
5913            else:
5914                raise TypeError(
5915                    f"Invalid connectTo value: {connectTo!r}"
5916                )
5917        elif connectTo is not None:
5918            try:
5919                connectID = self.resolveDecision(connectTo)
5920                # leave newName as None
5921            except MissingDecisionError:
5922                if isinstance(connectTo, int):
5923                    raise
5924                elif isinstance(connectTo, base.DecisionSpecifier):
5925                    newName = connectTo.name
5926                    # The domain & zone are ignored here
5927                else:  # Must just be a string
5928                    assert isinstance(connectTo, str)
5929                    newName = connectTo
5930        else:
5931            # If connectTo name wasn't specified, use current name of
5932            # unknown node unless it's a default name
5933            oldName = self.nameFor(oldUnexplored)
5934            if (
5935                oldName.startswith('_u.')
5936            and oldName[3:].isdigit()
5937            ):
5938                newName = utils.uniqueName('_x.' + oldName[3:], self)
5939            else:
5940                newName = oldName
5941
5942        # One or the other should be valid at this point
5943        assert connectID is not None or newName is not None
5944
5945        # Check that the old unknown doesn't have a reciprocal edge that
5946        # would collide with the specified return edge
5947        if reciprocal is not None:
5948            revFromUnknown = self.getDestination(oldUnexplored, reciprocal)
5949            if revFromUnknown not in (None, fromID):
5950                raise TransitionCollisionError(
5951                    f"Transition {reciprocal!r} from"
5952                    f" {self.identityOf(oldUnexplored)} exists and does"
5953                    f" not lead back to {self.identityOf(fromDecision)}"
5954                    f" (it leads to {self.identityOf(revFromUnknown)})."
5955                )
5956
5957        # Remember old reciprocal edge for future merging in case
5958        # it's not reciprocal
5959        oldReciprocal = self.getReciprocal(fromID, transition)
5960
5961        # Apply any new tags or annotations, or create a new node
5962        needsZoneInfo = False
5963        if connectID is not None:
5964            # Before applying tags, check if we need to error out
5965            # because of a reciprocal edge that points to a known
5966            # destination:
5967            if reciprocal is not None:
5968                otherOldUnknown: Optional[
5969                    base.DecisionID
5970                ] = self.getDestination(
5971                    connectID,
5972                    reciprocal
5973                )
5974                if (
5975                    otherOldUnknown is not None
5976                and self.isConfirmed(otherOldUnknown)
5977                ):
5978                    raise ExplorationStatusError(
5979                        f"Reciprocal transition {reciprocal!r} from"
5980                        f" {self.identityOf(connectTo)} does not lead"
5981                        f" to an unconfirmed decision (it leads to"
5982                        f" {self.identityOf(otherOldUnknown)})."
5983                    )
5984            self.tagDecision(connectID, decisionTags)
5985            self.annotateDecision(connectID, decisionAnnotations)
5986            # Still needs zone info if the place we're connecting to was
5987            # unconfirmed up until now, since unconfirmed nodes don't
5988            # normally get zone info when they're created.
5989            if not self.isConfirmed(connectID):
5990                needsZoneInfo = True
5991
5992            # First, merge the old unknown with the connectTo node...
5993            destRenames = self.mergeDecisions(
5994                oldUnexplored,
5995                connectID,
5996                errorOnNameColision=False
5997            )
5998        else:
5999            needsZoneInfo = True
6000            if len(self.zoneParents(oldUnexplored)) > 0:
6001                needsZoneInfo = False
6002            assert newName is not None
6003            self.renameDecision(oldUnexplored, newName)
6004            connectID = oldUnexplored
6005            # In this case there can't be an other old unknown
6006            otherOldUnknown = None
6007            destRenames = {}  # empty
6008
6009        # Check for domain mismatch to stifle zone updates:
6010        fromDomain = self.domainFor(fromID)
6011        if connectID is None:
6012            destDomain = self.domainFor(oldUnexplored)
6013        else:
6014            destDomain = self.domainFor(connectID)
6015
6016        # Stifle zone updates if there's a mismatch
6017        if fromDomain != destDomain:
6018            needsZoneInfo = False
6019
6020        # Records renames that happen at the source (from node)
6021        sourceRenames = {}  # empty for now
6022
6023        assert connectID is not None
6024
6025        # Apply the new zone if there is one
6026        if placeInZone is not None:
6027            if placeInZone is base.DefaultZone:
6028                # When using DefaultZone, changes are only made for new
6029                # destinations which don't already have any zones and
6030                # which are in the same domain as the departing node:
6031                # they get placed into each zone parent of the source
6032                # decision.
6033                if needsZoneInfo:
6034                    # Remove destination from all current parents
6035                    removeFrom = set(self.zoneParents(connectID))  # copy
6036                    for parent in removeFrom:
6037                        self.removeDecisionFromZone(connectID, parent)
6038                    # Add it to parents of origin
6039                    for parent in self.zoneParents(fromID):
6040                        self.addDecisionToZone(connectID, parent)
6041            else:
6042                placeInZone = cast(base.Zone, placeInZone)
6043                # Create the zone if it doesn't already exist
6044                if self.getZoneInfo(placeInZone) is None:
6045                    self.createZone(placeInZone, 0)
6046                    # Add it to each grandparent of the from decision
6047                    for parent in self.zoneParents(fromID):
6048                        for grandparent in self.zoneParents(parent):
6049                            self.addZoneToZone(placeInZone, grandparent)
6050                # Remove destination from all current parents
6051                for parent in set(self.zoneParents(connectID)):
6052                    self.removeDecisionFromZone(connectID, parent)
6053                # Add it to the specified zone
6054                self.addDecisionToZone(connectID, placeInZone)
6055
6056        # Next, if there is a reciprocal name specified, we do more...
6057        if reciprocal is not None:
6058            # Figure out what kind of merging needs to happen
6059            if otherOldUnknown is None:
6060                if revFromUnknown is None:
6061                    # Just create the desired reciprocal transition, which
6062                    # we know does not already exist
6063                    self.addTransition(connectID, reciprocal, fromID)
6064                    otherOldReciprocal = None
6065                else:
6066                    # Reciprocal exists, as revFromUnknown
6067                    otherOldReciprocal = None
6068            else:
6069                otherOldReciprocal = self.getReciprocal(
6070                    connectID,
6071                    reciprocal
6072                )
6073                # we need to merge otherOldUnknown into our fromDecision
6074                sourceRenames = self.mergeDecisions(
6075                    otherOldUnknown,
6076                    fromID,
6077                    errorOnNameColision=False
6078                )
6079                # Unvisited tag after merge only if both were
6080
6081            # No matter what happened we ensure the reciprocal
6082            # relationship is set up:
6083            self.setReciprocal(fromID, transition, reciprocal)
6084
6085            # Now we might need to merge some transitions:
6086            # - Any reciprocal of the target transition should be merged
6087            #   with reciprocal (if it was already reciprocal, that's a
6088            #   no-op).
6089            # - Any reciprocal of the reciprocal transition from the target
6090            #   node (leading to otherOldUnknown) should be merged with
6091            #   the target transition, even if it shared a name and was
6092            #   renamed as a result.
6093            # - If reciprocal was renamed during the initial merge, those
6094            #   transitions should be merged.
6095
6096            # Merge old reciprocal into reciprocal
6097            if oldReciprocal is not None:
6098                oldRev = destRenames.get(oldReciprocal, oldReciprocal)
6099                if self.getDestination(connectID, oldRev) is not None:
6100                    # Note that we don't want to auto-merge the reciprocal,
6101                    # which is the target transition
6102                    self.mergeTransitions(
6103                        connectID,
6104                        oldRev,
6105                        reciprocal,
6106                        mergeReciprocal=False
6107                    )
6108                    # Remove it from the renames map
6109                    if oldReciprocal in destRenames:
6110                        del destRenames[oldReciprocal]
6111
6112            # Merge reciprocal reciprocal from otherOldUnknown
6113            if otherOldReciprocal is not None:
6114                otherOldRev = sourceRenames.get(
6115                    otherOldReciprocal,
6116                    otherOldReciprocal
6117                )
6118                # Note that the reciprocal is reciprocal, which we don't
6119                # need to merge
6120                self.mergeTransitions(
6121                    fromID,
6122                    otherOldRev,
6123                    transition,
6124                    mergeReciprocal=False
6125                )
6126                # Remove it from the renames map
6127                if otherOldReciprocal in sourceRenames:
6128                    del sourceRenames[otherOldReciprocal]
6129
6130            # Merge any renamed reciprocal onto reciprocal
6131            if reciprocal in destRenames:
6132                extraRev = destRenames[reciprocal]
6133                self.mergeTransitions(
6134                    connectID,
6135                    extraRev,
6136                    reciprocal,
6137                    mergeReciprocal=False
6138                )
6139                # Remove it from the renames map
6140                del destRenames[reciprocal]
6141
6142        # Accumulate new tags & annotations for the transitions
6143        self.tagTransition(fromID, transition, tags)
6144        self.annotateTransition(fromID, transition, annotations)
6145
6146        if reciprocal is not None:
6147            self.tagTransition(connectID, reciprocal, revTags)
6148            self.annotateTransition(connectID, reciprocal, revAnnotations)
6149
6150        # Override copied requirement/consequences for the transitions
6151        if requirement is not None:
6152            self.setTransitionRequirement(
6153                fromID,
6154                transition,
6155                requirement
6156            )
6157        if applyConsequence is not None:
6158            self.setConsequence(
6159                fromID,
6160                transition,
6161                applyConsequence
6162            )
6163
6164        if reciprocal is not None:
6165            if revRequires is not None:
6166                self.setTransitionRequirement(
6167                    connectID,
6168                    reciprocal,
6169                    revRequires
6170                )
6171            if revConsequence is not None:
6172                self.setConsequence(
6173                    connectID,
6174                    reciprocal,
6175                    revConsequence
6176                )
6177
6178        # Remove 'unconfirmed' tag if it was present
6179        self.untagDecision(connectID, 'unconfirmed')
6180
6181        # Final checks
6182        assert self.getDestination(fromDecision, transition) == connectID
6183        useConnect: base.AnyDecisionSpecifier
6184        useRev: Optional[str]
6185        if connectTo is None:
6186            useConnect = connectID
6187        else:
6188            useConnect = connectTo
6189        if reciprocal is None:
6190            useRev = self.getReciprocal(fromDecision, transition)
6191        else:
6192            useRev = reciprocal
6193        if useRev is not None:
6194            try:
6195                assert self.getDestination(useConnect, useRev) == fromID
6196            except AmbiguousDecisionSpecifierError:
6197                assert self.getDestination(connectID, useRev) == fromID
6198
6199        # Return our final rename dictionaries
6200        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 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:
6202    def endingID(self, name: base.DecisionName) -> base.DecisionID:
6203        """
6204        Returns the decision ID for the ending with the specified name.
6205        Endings are disconnected decisions in the `ENDINGS_DOMAIN`; they
6206        don't normally include any zone information. If no ending with
6207        the specified name already existed, then a new ending with that
6208        name will be created and its Decision ID will be returned.
6209
6210        If a new decision is created, it will be tagged as unconfirmed.
6211
6212        Note that endings mostly aren't special: they're normal
6213        decisions in a separate singular-focalized domain. However, some
6214        parts of the exploration and journal machinery treat them
6215        differently (in particular, taking certain actions via
6216        `advanceSituation` while any decision in the `ENDINGS_DOMAIN` is
6217        active is an error.
6218        """
6219        # Create our new ending decision if we need to
6220        try:
6221            endID = self.resolveDecision(
6222                base.DecisionSpecifier(ENDINGS_DOMAIN, None, name)
6223            )
6224        except MissingDecisionError:
6225            # Create a new decision for the ending
6226            endID = self.addDecision(name, domain=ENDINGS_DOMAIN)
6227            # Tag it as unconfirmed
6228            self.tagDecision(endID, 'unconfirmed')
6229
6230        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:
6232    def triggerGroupID(self, name: base.DecisionName) -> base.DecisionID:
6233        """
6234        Given the name of a trigger group, returns the ID of the special
6235        node representing that trigger group in the `TRIGGERS_DOMAIN`.
6236        If the specified group didn't already exist, it will be created.
6237
6238        Trigger group decisions are not special: they just exist in a
6239        separate spreading-focalized domain and have a few API methods to
6240        access them, but all the normal decision-related API methods
6241        still work. Their intended use is for sets of global triggers,
6242        by attaching actions with the 'trigger' tag to them and then
6243        activating or deactivating them as needed.
6244        """
6245        result = self.getDecision(
6246            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
6247        )
6248        if result is None:
6249            return self.addDecision(name, domain=TRIGGERS_DOMAIN)
6250        else:
6251            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:
6253    @staticmethod
6254    def example(which: Literal['simple', 'abc']) -> 'DecisionGraph':
6255        """
6256        Returns one of a number of example decision graphs, depending on
6257        the string given. It returns a fresh copy each time. The graphs
6258        are:
6259
6260        - 'simple': Three nodes named 'A', 'B', and 'C' with IDs 0, 1,
6261            and 2, each connected to the next in the sequence by a
6262            'next' transition with reciprocal 'prev'. In other words, a
6263            simple little triangle. There are no tags, annotations,
6264            requirements, consequences, mechanisms, or equivalences.
6265        - 'abc': A more complicated 3-node setup that introduces a
6266            little bit of everything. In this graph, we have the same
6267            three nodes, but different transitions:
6268
6269                * From A you can go 'left' to B with reciprocal 'right'.
6270                * From A you can also go 'up_left' to B with reciprocal
6271                    'up_right'. These transitions both require the
6272                    'grate' mechanism (which is at decision A) to be in
6273                    state 'open'.
6274                * From A you can go 'down' to C with reciprocal 'up'.
6275
6276            (In this graph, B and C are not directly connected to each
6277            other.)
6278
6279            The graph has two level-0 zones 'zoneA' and 'zoneB', along
6280            with a level-1 zone 'upZone'. Decisions A and C are in
6281            zoneA while B is in zoneB; zoneA is in upZone, but zoneB is
6282            not.
6283
6284            The decision A has annotation:
6285
6286                'This is a multi-word "annotation."'
6287
6288            The transition 'down' from A has annotation:
6289
6290                "Transition 'annotation.'"
6291
6292            Decision B has tags 'b' with value 1 and 'tag2' with value
6293            '"value"'.
6294
6295            Decision C has tag 'aw"ful' with value "ha'ha'".
6296
6297            Transition 'up' from C has tag 'fast' with value 1.
6298
6299            At decision C there are actions 'grab_helmet' and
6300            'pull_lever'.
6301
6302            The 'grab_helmet' transition requires that you don't have
6303            the 'helmet' capability, and gives you that capability,
6304            deactivating with delay 3.
6305
6306            The 'pull_lever' transition requires that you do have the
6307            'helmet' capability, and takes away that capability, but it
6308            also gives you 1 token, and if you have 2 tokens (before
6309            getting the one extra), it sets the 'grate' mechanism (which
6310            is a decision A) to state 'open' and deactivates.
6311
6312            The graph has an equivalence: having the 'helmet' capability
6313            satisfies requirements for the 'grate' mechanism to be in the
6314            'open' state.
6315
6316        """
6317        result = DecisionGraph()
6318        if which == 'simple':
6319            result.addDecision('A')  # id 0
6320            result.addDecision('B')  # id 1
6321            result.addDecision('C')  # id 2
6322            result.addTransition('A', 'next', 'B', 'prev')
6323            result.addTransition('B', 'next', 'C', 'prev')
6324            result.addTransition('C', 'next', 'A', 'prev')
6325        elif which == 'abc':
6326            result.addDecision('A')  # id 0
6327            result.addDecision('B')  # id 1
6328            result.addDecision('C')  # id 2
6329            result.createZone('zoneA', 0)
6330            result.createZone('zoneB', 0)
6331            result.createZone('upZone', 1)
6332            result.addZoneToZone('zoneA', 'upZone')
6333            result.addDecisionToZone('A', 'zoneA')
6334            result.addDecisionToZone('B', 'zoneB')
6335            result.addDecisionToZone('C', 'zoneA')
6336            result.addTransition('A', 'left', 'B', 'right')
6337            result.addTransition('A', 'up_left', 'B', 'up_right')
6338            result.addTransition('A', 'down', 'C', 'up')
6339            result.setTransitionRequirement(
6340                'A',
6341                'up_left',
6342                base.ReqMechanism('grate', 'open')
6343            )
6344            result.setTransitionRequirement(
6345                'B',
6346                'up_right',
6347                base.ReqMechanism('grate', 'open')
6348            )
6349            result.annotateDecision('A', 'This is a multi-word "annotation."')
6350            result.annotateTransition('A', 'down', "Transition 'annotation.'")
6351            result.tagDecision('B', 'b')
6352            result.tagDecision('B', 'tag2', '"value"')
6353            result.tagDecision('C', 'aw"ful', "ha'ha")
6354            result.tagTransition('C', 'up', 'fast')
6355            result.addMechanism('grate', 'A')
6356            result.addAction(
6357                'C',
6358                'grab_helmet',
6359                base.ReqNot(base.ReqCapability('helmet')),
6360                [
6361                    base.effect(gain='helmet'),
6362                    base.effect(deactivate=True, delay=3)
6363                ]
6364            )
6365            result.addAction(
6366                'C',
6367                'pull_lever',
6368                base.ReqCapability('helmet'),
6369                [
6370                    base.effect(lose='helmet'),
6371                    base.effect(gain=('token', 1)),
6372                    base.condition(
6373                        base.ReqTokens('token', 2),
6374                        [
6375                            base.effect(set=('grate', 'open')),
6376                            base.effect(deactivate=True)
6377                        ]
6378                    )
6379                ]
6380            )
6381            result.addEquivalence(
6382                base.ReqCapability('helmet'),
6383                (0, 'open')
6384            )
6385        else:
6386            raise ValueError(f"Invalid example name: {which!r}")
6387
6388        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
allEdgesTo
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:
6395def emptySituation() -> base.Situation:
6396    """
6397    Creates and returns an empty situation: A situation that has an
6398    empty `DecisionGraph`, an empty `State`, a 'pending' decision type
6399    with `None` as the action taken, no tags, and no annotations.
6400    """
6401    return base.Situation(
6402        graph=DecisionGraph(),
6403        state=base.emptyState(),
6404        type='pending',
6405        action=None,
6406        saves={},
6407        tags={},
6408        annotations=[]
6409    )

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

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).

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 possible (see the quest, progress, complete, destination, and arrive methods). TODO: That

situations: List[exploration.base.Situation]
@staticmethod
def fromGraph( graph: DecisionGraph, state: Optional[exploration.base.State] = None) -> DiscreteExploration:
6463    @staticmethod
6464    def fromGraph(
6465        graph: DecisionGraph,
6466        state: Optional[base.State] = None
6467    ) -> 'DiscreteExploration':
6468        """
6469        Creates an exploration which has just a single step whose graph
6470        is the entire specified graph, with the specified decision as
6471        the primary decision (if any). The graph is copied, so that
6472        changes to the exploration will not modify it. A starting state
6473        may also be specified if desired, although if not an empty state
6474        will be used (a provided starting state is NOT copied, but used
6475        directly).
6476
6477        Example:
6478
6479        >>> g = DecisionGraph()
6480        >>> g.addDecision('Room1')
6481        0
6482        >>> g.addDecision('Room2')
6483        1
6484        >>> g.addTransition('Room1', 'door', 'Room2', 'door')
6485        >>> e = DiscreteExploration.fromGraph(g)
6486        >>> len(e)
6487        1
6488        >>> e.getSituation().graph == g
6489        True
6490        >>> e.getActiveDecisions()
6491        set()
6492        >>> e.primaryDecision() is None
6493        True
6494        >>> e.observe('Room1', 'hatch')
6495        2
6496        >>> e.getSituation().graph == g
6497        False
6498        >>> e.getSituation().graph.destinationsFrom('Room1')
6499        {'door': 1, 'hatch': 2}
6500        >>> g.destinationsFrom('Room1')
6501        {'door': 1}
6502        """
6503        result = DiscreteExploration()
6504        result.situations[0] = base.Situation(
6505            graph=copy.deepcopy(graph),
6506            state=base.emptyState() if state is None else state,
6507            type='pending',
6508            action=None,
6509            saves={},
6510            tags={},
6511            annotations=[]
6512        )
6513        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:
6534    def getSituation(self, step: int = -1) -> base.Situation:
6535        """
6536        Returns a `base.Situation` named tuple detailing the state of
6537        the exploration at a given step (or at the current step if no
6538        argument is given). Note that this method works the same
6539        way as indexing the exploration: see `__getitem__`.
6540
6541        Raises an `IndexError` if asked for a step that's out-of-range.
6542        """
6543        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]:
6545    def primaryDecision(self, step: int = -1) -> Optional[base.DecisionID]:
6546        """
6547        Returns the current primary `base.DecisionID`, or the primary
6548        decision from a specific step if one is specified. This may be
6549        `None` for some steps, but mostly it's the destination of the
6550        transition taken in the previous step.
6551        """
6552        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:
6554    def effectiveCapabilities(
6555        self,
6556        step: int = -1
6557    ) -> base.CapabilitySet:
6558        """
6559        Returns the effective capability set for the specified step
6560        (default is the last/current step). See
6561        `base.effectiveCapabilities`.
6562        """
6563        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:
6565    def getCommonContext(
6566        self,
6567        step: Optional[int] = None
6568    ) -> base.FocalContext:
6569        """
6570        Returns the common `FocalContext` at the specified step, or at
6571        the current step if no argument is given. Raises an `IndexError`
6572        if an invalid step is specified.
6573        """
6574        if step is None:
6575            step = -1
6576        state = self.getSituation(step).state
6577        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:
6579    def getActiveContext(
6580        self,
6581        step: Optional[int] = None
6582    ) -> base.FocalContext:
6583        """
6584        Returns the active `FocalContext` at the specified step, or at
6585        the current step if no argument is provided. Raises an
6586        `IndexError` if an invalid step is specified.
6587        """
6588        if step is None:
6589            step = -1
6590        state = self.getSituation(step).state
6591        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:
6593    def addFocalContext(self, name: base.FocalContextName) -> None:
6594        """
6595        Adds a new empty focal context to our set of focal contexts (see
6596        `emptyFocalContext`). Use `setActiveContext` to swap to it.
6597        Raises a `FocalContextCollisionError` if the name is already in
6598        use.
6599        """
6600        contextMap = self.getSituation().state['contexts']
6601        if name in contextMap:
6602            raise FocalContextCollisionError(
6603                f"Cannot add focal context {name!r}: a focal context"
6604                f" with that name already exists."
6605            )
6606        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:
6608    def setActiveContext(self, which: base.FocalContextName) -> None:
6609        """
6610        Sets the active context to the named focal context, creating it
6611        if it did not already exist (makes changes to the current
6612        situation only). Does not add an exploration step (use
6613        `advanceSituation` with a 'swap' action for that).
6614        """
6615        state = self.getSituation().state
6616        contextMap = state['contexts']
6617        if which not in contextMap:
6618            self.addFocalContext(which)
6619        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:
6621    def createDomain(
6622        self,
6623        name: base.Domain,
6624        focalization: base.DomainFocalization = 'singular',
6625        makeActive: bool = False,
6626        inCommon: Union[bool, Literal["both"]] = "both"
6627    ) -> None:
6628        """
6629        Creates a new domain with the given focalization type, in either
6630        the common context (`inCommon` = `True`) the active context
6631        (`inCommon` = `False`) or both (the default; `inCommon` = 'both').
6632        The domain's focalization will be set to the given
6633        `focalization` value (default 'singular') and it will have no
6634        active decisions. Raises a `DomainCollisionError` if a domain
6635        with the specified name already exists.
6636
6637        Creates the domain in the current situation.
6638
6639        If `makeActive` is set to `True` (default is `False`) then the
6640        domain will be made active in whichever context(s) it's created
6641        in.
6642        """
6643        now = self.getSituation()
6644        state = now.state
6645        modify = []
6646        if inCommon in (True, "both"):
6647            modify.append(('common', state['common']))
6648        if inCommon in (False, "both"):
6649            acName = state['activeContext']
6650            modify.append(
6651                ('current ({repr(acName)})', state['contexts'][acName])
6652            )
6653
6654        for (fcType, fc) in modify:
6655            if name in fc['focalization']:
6656                raise DomainCollisionError(
6657                    f"Cannot create domain {repr(name)} because a"
6658                    f" domain with that name already exists in the"
6659                    f" {fcType} focal context."
6660                )
6661            fc['focalization'][name] = focalization
6662            if makeActive:
6663                fc['activeDomains'].add(name)
6664            if focalization == "spreading":
6665                fc['activeDecisions'][name] = set()
6666            elif focalization == "plural":
6667                fc['activeDecisions'][name] = {}
6668            else:
6669                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:
6671    def activateDomain(
6672        self,
6673        domain: base.Domain,
6674        activate: bool = True,
6675        inContext: base.ContextSpecifier = "active"
6676    ) -> None:
6677        """
6678        Sets the given domain as active (or inactive if 'activate' is
6679        given as `False`) in the specified context (default "active").
6680
6681        Modifies the current situation.
6682        """
6683        fc: base.FocalContext
6684        if inContext == "active":
6685            fc = self.getActiveContext()
6686        elif inContext == "common":
6687            fc = self.getCommonContext()
6688
6689        if activate:
6690            fc['activeDomains'].add(domain)
6691        else:
6692            try:
6693                fc['activeDomains'].remove(domain)
6694            except KeyError:
6695                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:
6697    def createTriggerGroup(
6698        self,
6699        name: base.DecisionName
6700    ) -> base.DecisionID:
6701        """
6702        Creates a new trigger group with the given name, returning the
6703        decision ID for that trigger group. If this is the first trigger
6704        group being created, also creates the `TRIGGERS_DOMAIN` domain
6705        as a spreading-focalized domain that's active in the common
6706        context (but does NOT set the created trigger group as an active
6707        decision in that domain).
6708
6709        You can use 'goto' effects to activate trigger domains via
6710        consequences, and 'retreat' effects to deactivate them.
6711
6712        Creating a second trigger group with the same name as another
6713        results in a `ValueError`.
6714
6715        TODO: Retreat effects
6716        """
6717        ctx = self.getCommonContext()
6718        if TRIGGERS_DOMAIN not in ctx['focalization']:
6719            self.createDomain(
6720                TRIGGERS_DOMAIN,
6721                focalization='spreading',
6722                makeActive=True,
6723                inCommon=True
6724            )
6725
6726        graph = self.getSituation().graph
6727        if graph.getDecision(
6728            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
6729        ) is not None:
6730            raise ValueError(
6731                f"Cannot create trigger group {name!r}: a trigger group"
6732                f" with that name already exists."
6733            )
6734
6735        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):
6737    def toggleTriggerGroup(
6738        self,
6739        name: base.DecisionName,
6740        setActive: Union[bool, None] = None
6741    ):
6742        """
6743        Toggles whether the specified trigger group (a decision in the
6744        `TRIGGERS_DOMAIN`) is active or not. Pass `True` or `False` as
6745        the `setActive` argument (instead of the default `None`) to set
6746        the state directly instead of toggling it.
6747
6748        Note that trigger groups are decisions in a spreading-focalized
6749        domain, so they can be activated or deactivated by the 'goto'
6750        and 'retreat' effects as well.
6751
6752        This does not affect whether the `TRIGGERS_DOMAIN` itself is
6753        active (normally it would always be active).
6754
6755        Raises a `MissingDecisionError` if the specified trigger group
6756        does not exist yet, including when the entire `TRIGGERS_DOMAIN`
6757        does not exist. Raises a `KeyError` if the target group exists
6758        but the `TRIGGERS_DOMAIN` has not been set up properly.
6759        """
6760        ctx = self.getCommonContext()
6761        tID = self.getSituation().graph.resolveDecision(
6762            base.DecisionSpecifier(TRIGGERS_DOMAIN, None, name)
6763        )
6764        activeGroups = ctx['activeDecisions'][TRIGGERS_DOMAIN]
6765        assert isinstance(activeGroups, set)
6766        if tID in activeGroups:
6767            if setActive is not True:
6768                activeGroups.remove(tID)
6769        else:
6770            if setActive is not False:
6771                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]:
6773    def getActiveDecisions(
6774        self,
6775        step: Optional[int] = None,
6776        inCommon: Union[bool, Literal["both"]] = "both"
6777    ) -> Set[base.DecisionID]:
6778        """
6779        Returns the set of active decisions at the given step index, or
6780        at the current step if no step is specified. Raises an
6781        `IndexError` if the step index is out of bounds (see `__len__`).
6782        May return an empty set if no decisions are active.
6783
6784        If `inCommon` is set to "both" (the default) then decisions
6785        active in either the common or active context are returned. Set
6786        it to `True` or `False` to return only decisions active in the
6787        common (when `True`) or  active (when `False`) context.
6788        """
6789        if step is None:
6790            step = -1
6791        state = self.getSituation(step).state
6792        if inCommon == "both":
6793            return base.combinedDecisionSet(state)
6794        elif inCommon is True:
6795            return base.activeDecisionSet(state['common'])
6796        elif inCommon is False:
6797            return base.activeDecisionSet(
6798                state['contexts'][state['activeContext']]
6799            )
6800        else:
6801            raise ValueError(
6802                f"Invalid inCommon value {repr(inCommon)} (must be"
6803                f" 'both', True, or False)."
6804            )

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:
6806    def setActiveDecisionsAtStep(
6807        self,
6808        step: int,
6809        domain: base.Domain,
6810        activate: Union[
6811            base.DecisionID,
6812            Dict[base.FocalPointName, Optional[base.DecisionID]],
6813            Set[base.DecisionID]
6814        ],
6815        inCommon: bool = False
6816    ) -> None:
6817        """
6818        Changes the activation status of decisions in the active
6819        `FocalContext` at the specified step, for the specified domain
6820        (see `currentActiveContext`). Does this without adding an
6821        exploration step, which is unusual: normally you should use
6822        another method like `warp` to update active decisions.
6823
6824        Note that this does not change which domains are active, and
6825        setting active decisions in inactive domains does not make those
6826        decisions active overall.
6827
6828        Which decisions to activate or deactivate are specified as
6829        either a single `DecisionID`, a list of them, or a set of them,
6830        depending on the `DomainFocalization` setting in the selected
6831        `FocalContext` for the specified domain. A `TypeError` will be
6832        raised if the wrong kind of decision information is provided. If
6833        the focalization context does not have any focalization value for
6834        the domain in question, it will be set based on the kind of
6835        active decision information specified.
6836
6837        A `MissingDecisionError` will be raised if a decision is
6838        included which is not part of the current `DecisionGraph`.
6839        The provided information will overwrite the previous active
6840        decision information.
6841
6842        If `inCommon` is set to `True`, then decisions are activated or
6843        deactivated in the common context, instead of in the active
6844        context.
6845
6846        Example:
6847
6848        >>> e = DiscreteExploration()
6849        >>> e.getActiveDecisions()
6850        set()
6851        >>> graph = e.getSituation().graph
6852        >>> graph.addDecision('A')
6853        0
6854        >>> graph.addDecision('B')
6855        1
6856        >>> graph.addDecision('C')
6857        2
6858        >>> e.setActiveDecisionsAtStep(0, 'main', 0)
6859        >>> e.getActiveDecisions()
6860        {0}
6861        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
6862        >>> e.getActiveDecisions()
6863        {1}
6864        >>> graph = e.getSituation().graph
6865        >>> graph.addDecision('One', domain='numbers')
6866        3
6867        >>> graph.addDecision('Two', domain='numbers')
6868        4
6869        >>> graph.addDecision('Three', domain='numbers')
6870        5
6871        >>> graph.addDecision('Bear', domain='animals')
6872        6
6873        >>> graph.addDecision('Spider', domain='animals')
6874        7
6875        >>> graph.addDecision('Eel', domain='animals')
6876        8
6877        >>> ac = e.getActiveContext()
6878        >>> ac['focalization']['numbers'] = 'plural'
6879        >>> ac['focalization']['animals'] = 'spreading'
6880        >>> ac['activeDecisions']['numbers'] = {'a': None, 'b': None}
6881        >>> ac['activeDecisions']['animals'] = set()
6882        >>> cc = e.getCommonContext()
6883        >>> cc['focalization']['numbers'] = 'plural'
6884        >>> cc['focalization']['animals'] = 'spreading'
6885        >>> cc['activeDecisions']['numbers'] = {'z': None}
6886        >>> cc['activeDecisions']['animals'] = set()
6887        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 3, 'b': 3})
6888        >>> e.getActiveDecisions()
6889        {1}
6890        >>> e.activateDomain('numbers')
6891        >>> e.getActiveDecisions()
6892        {1, 3}
6893        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 4, 'b': None})
6894        >>> e.getActiveDecisions()
6895        {1, 4}
6896        >>> # Wrong domain for the decision ID:
6897        >>> e.setActiveDecisionsAtStep(0, 'main', 3)
6898        Traceback (most recent call last):
6899        ...
6900        ValueError...
6901        >>> # Wrong domain for one of the decision IDs:
6902        >>> e.setActiveDecisionsAtStep(0, 'numbers', {'a': 2, 'b': None})
6903        Traceback (most recent call last):
6904        ...
6905        ValueError...
6906        >>> # Wrong kind of decision information provided.
6907        >>> e.setActiveDecisionsAtStep(0, 'numbers', 3)
6908        Traceback (most recent call last):
6909        ...
6910        TypeError...
6911        >>> e.getActiveDecisions()
6912        {1, 4}
6913        >>> e.setActiveDecisionsAtStep(0, 'animals', {6, 7})
6914        >>> e.getActiveDecisions()
6915        {1, 4}
6916        >>> e.activateDomain('animals')
6917        >>> e.getActiveDecisions()
6918        {1, 4, 6, 7}
6919        >>> e.setActiveDecisionsAtStep(0, 'animals', {8})
6920        >>> e.getActiveDecisions()
6921        {8, 1, 4}
6922        >>> e.setActiveDecisionsAtStep(1, 'main', 2)  # invalid step
6923        Traceback (most recent call last):
6924        ...
6925        IndexError...
6926        >>> e.setActiveDecisionsAtStep(0, 'novel', 0)  # domain mismatch
6927        Traceback (most recent call last):
6928        ...
6929        ValueError...
6930
6931        Example of active/common contexts:
6932
6933        >>> e = DiscreteExploration()
6934        >>> graph = e.getSituation().graph
6935        >>> graph.addDecision('A')
6936        0
6937        >>> graph.addDecision('B')
6938        1
6939        >>> e.activateDomain('main', inContext="common")
6940        >>> e.setActiveDecisionsAtStep(0, 'main', 0, inCommon=True)
6941        >>> e.getActiveDecisions()
6942        {0}
6943        >>> e.setActiveDecisionsAtStep(0, 'main', None)
6944        >>> e.getActiveDecisions()
6945        {0}
6946        >>> # (Still active since it's active in the common context)
6947        >>> e.setActiveDecisionsAtStep(0, 'main', 1)
6948        >>> e.getActiveDecisions()
6949        {0, 1}
6950        >>> e.setActiveDecisionsAtStep(0, 'main', 1, inCommon=True)
6951        >>> e.getActiveDecisions()
6952        {1}
6953        >>> e.setActiveDecisionsAtStep(0, 'main', None, inCommon=True)
6954        >>> e.getActiveDecisions()
6955        {1}
6956        >>> # (Still active since it's active in the active context)
6957        >>> e.setActiveDecisionsAtStep(0, 'main', None)
6958        >>> e.getActiveDecisions()
6959        set()
6960        """
6961        now = self.getSituation(step)
6962        graph = now.graph
6963        if inCommon:
6964            context = self.getCommonContext(step)
6965        else:
6966            context = self.getActiveContext(step)
6967
6968        defaultFocalization: base.DomainFocalization = 'singular'
6969        if isinstance(activate, base.DecisionID):
6970            defaultFocalization = 'singular'
6971        elif isinstance(activate, dict):
6972            defaultFocalization = 'plural'
6973        elif isinstance(activate, set):
6974            defaultFocalization = 'spreading'
6975        elif domain not in context['focalization']:
6976            raise TypeError(
6977                f"Domain {domain!r} has no focalization in the"
6978                f" {'common' if inCommon else 'active'} context,"
6979                f" and the specified position doesn't imply one."
6980            )
6981
6982        focalization = base.getDomainFocalization(
6983            context,
6984            domain,
6985            defaultFocalization
6986        )
6987
6988        # Check domain & existence of decision(s) in question
6989        if activate is None:
6990            pass
6991        elif isinstance(activate, base.DecisionID):
6992            if activate not in graph:
6993                raise MissingDecisionError(
6994                    f"There is no decision {activate} at step {step}."
6995                )
6996            if graph.domainFor(activate) != domain:
6997                raise ValueError(
6998                    f"Can't set active decisions in domain {domain!r}"
6999                    f" to decision {graph.identityOf(activate)} because"
7000                    f" that decision is in actually in domain"
7001                    f" {graph.domainFor(activate)!r}."
7002                )
7003        elif isinstance(activate, dict):
7004            for fpName, pos in activate.items():
7005                if pos is None:
7006                    continue
7007                if pos not in graph:
7008                    raise MissingDecisionError(
7009                        f"There is no decision {pos} at step {step}."
7010                    )
7011                if graph.domainFor(pos) != domain:
7012                    raise ValueError(
7013                        f"Can't set active decision for focal point"
7014                        f" {fpName!r} in domain {domain!r}"
7015                        f" to decision {graph.identityOf(pos)} because"
7016                        f" that decision is in actually in domain"
7017                        f" {graph.domainFor(pos)!r}."
7018                    )
7019        elif isinstance(activate, set):
7020            for pos in activate:
7021                if pos not in graph:
7022                    raise MissingDecisionError(
7023                        f"There is no decision {pos} at step {step}."
7024                    )
7025                if graph.domainFor(pos) != domain:
7026                    raise ValueError(
7027                        f"Can't set {graph.identityOf(pos)} as an"
7028                        f" active decision in domain {domain!r} to"
7029                        f" decision because that decision is in"
7030                        f" actually in domain {graph.domainFor(pos)!r}."
7031                    )
7032        else:
7033            raise TypeError(
7034                f"Domain {domain!r} has no focalization in the"
7035                f" {'common' if inCommon else 'active'} context,"
7036                f" and the specified position doesn't imply one:"
7037                f"\n{activate!r}"
7038            )
7039
7040        if focalization == 'singular':
7041            if activate is None or isinstance(activate, base.DecisionID):
7042                if activate is not None:
7043                    targetDomain = graph.domainFor(activate)
7044                    if activate not in graph:
7045                        raise MissingDecisionError(
7046                            f"There is no decision {activate} in the"
7047                            f" graph at step {step}."
7048                        )
7049                    elif targetDomain != domain:
7050                        raise ValueError(
7051                            f"At step {step}, decision {activate} cannot"
7052                            f" be the active decision for domain"
7053                            f" {repr(domain)} because it is in a"
7054                            f" different domain ({repr(targetDomain)})."
7055                        )
7056                context['activeDecisions'][domain] = activate
7057            else:
7058                raise TypeError(
7059                    f"{'Common' if inCommon else 'Active'} focal"
7060                    f" context at step {step} has {repr(focalization)}"
7061                    f" focalization for domain {repr(domain)}, so the"
7062                    f" active decision must be a single decision or"
7063                    f" None.\n(You provided: {repr(activate)})"
7064                )
7065        elif focalization == 'plural':
7066            if (
7067                isinstance(activate, dict)
7068            and all(
7069                    isinstance(k, base.FocalPointName)
7070                    for k in activate.keys()
7071                )
7072            and all(
7073                    v is None or isinstance(v, base.DecisionID)
7074                    for v in activate.values()
7075                )
7076            ):
7077                for v in activate.values():
7078                    if v is not None:
7079                        targetDomain = graph.domainFor(v)
7080                        if v not in graph:
7081                            raise MissingDecisionError(
7082                                f"There is no decision {v} in the graph"
7083                                f" at step {step}."
7084                            )
7085                        elif targetDomain != domain:
7086                            raise ValueError(
7087                                f"At step {step}, decision {activate}"
7088                                f" cannot be an active decision for"
7089                                f" domain {repr(domain)} because it is"
7090                                f" in a different domain"
7091                                f" ({repr(targetDomain)})."
7092                            )
7093                context['activeDecisions'][domain] = activate
7094            else:
7095                raise TypeError(
7096                    f"{'Common' if inCommon else 'Active'} focal"
7097                    f" context at step {step} has {repr(focalization)}"
7098                    f" focalization for domain {repr(domain)}, so the"
7099                    f" active decision must be a dictionary mapping"
7100                    f" focal point names to decision IDs (or Nones)."
7101                    f"\n(You provided: {repr(activate)})"
7102                )
7103        elif focalization == 'spreading':
7104            if (
7105                isinstance(activate, set)
7106            and all(isinstance(x, base.DecisionID) for x in activate)
7107            ):
7108                for x in activate:
7109                    targetDomain = graph.domainFor(x)
7110                    if x not in graph:
7111                        raise MissingDecisionError(
7112                            f"There is no decision {x} in the graph"
7113                            f" at step {step}."
7114                        )
7115                    elif targetDomain != domain:
7116                        raise ValueError(
7117                            f"At step {step}, decision {activate}"
7118                            f" cannot be an active decision for"
7119                            f" domain {repr(domain)} because it is"
7120                            f" in a different domain"
7121                            f" ({repr(targetDomain)})."
7122                        )
7123                context['activeDecisions'][domain] = activate
7124            else:
7125                raise TypeError(
7126                    f"{'Common' if inCommon else 'Active'} focal"
7127                    f" context at step {step} has {repr(focalization)}"
7128                    f" focalization for domain {repr(domain)}, so the"
7129                    f" active decision must be a set of decision IDs"
7130                    f"\n(You provided: {repr(activate)})"
7131                )
7132        else:
7133            raise RuntimeError(
7134                f"Invalid focalization value {repr(focalization)} for"
7135                f" domain {repr(domain)} at step {step}."
7136            )

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]]:
7138    def movementAtStep(self, step: int = -1) -> Tuple[
7139        Union[base.DecisionID, Set[base.DecisionID], None],
7140        Optional[base.Transition],
7141        Union[base.DecisionID, Set[base.DecisionID], None]
7142    ]:
7143        """
7144        Given a step number, returns information about the starting
7145        decision, transition taken, and destination decision for that
7146        step. Not all steps have all of those, so some items may be
7147        `None`.
7148
7149        For steps where there is no action, where a decision is still
7150        pending, or where the action type is 'focus', 'swap',
7151        or 'focalize', the result will be (`None`, `None`, `None`),
7152        unless a primary decision is available in which case the first
7153        item in the tuple will be that decision. For 'start' actions, the
7154        starting position and transition will be `None` (again unless the
7155        step had a primary decision) but the destination will be the ID
7156        of the node started at.
7157
7158        Also, if the action taken has multiple potential or actual start
7159        or end points, these may be sets of decision IDs instead of
7160        single IDs.
7161
7162        Note that the primary decision of the starting state is usually
7163        used as the from-decision, but in some cases an action dictates
7164        taking a transition from a different decision, and this function
7165        will return that decision as the from-decision.
7166
7167        TODO: Examples!
7168
7169        TODO: Account for bounce/follow/goto effects!!!
7170        """
7171        now = self.getSituation(step)
7172        action = now.action
7173        graph = now.graph
7174        primary = now.state['primaryDecision']
7175
7176        if action is None:
7177            return (primary, None, None)
7178
7179        aType = action[0]
7180        fromID: Optional[base.DecisionID]
7181        destID: Optional[base.DecisionID]
7182        transition: base.Transition
7183        outcomes: List[bool]
7184
7185        if aType in ('noAction', 'focus', 'swap', 'focalize'):
7186            return (primary, None, None)
7187        elif aType == 'start':
7188            assert len(action) == 7
7189            where = cast(
7190                Union[
7191                    base.DecisionID,
7192                    Dict[base.FocalPointName, base.DecisionID],
7193                    Set[base.DecisionID]
7194                ],
7195                action[1]
7196            )
7197            if isinstance(where, dict):
7198                where = set(where.values())
7199            return (primary, None, where)
7200        elif aType in ('take', 'explore'):
7201            if (
7202                (len(action) == 4 or len(action) == 7)
7203            and isinstance(action[2], base.DecisionID)
7204            ):
7205                fromID = action[2]
7206                assert isinstance(action[3], tuple)
7207                transition, outcomes = action[3]
7208                if (
7209                    action[0] == "explore"
7210                and isinstance(action[4], base.DecisionID)
7211                ):
7212                    destID = action[4]
7213                else:
7214                    destID = graph.getDestination(fromID, transition)
7215                return (fromID, transition, destID)
7216            elif (
7217                (len(action) == 3 or len(action) == 6)
7218            and isinstance(action[1], tuple)
7219            and isinstance(action[2], base.Transition)
7220            and len(action[1]) == 3
7221            and action[1][0] in get_args(base.ContextSpecifier)
7222            and isinstance(action[1][1], base.Domain)
7223            and isinstance(action[1][2], base.FocalPointName)
7224            ):
7225                fromID = base.resolvePosition(now, action[1])
7226                if fromID is None:
7227                    raise InvalidActionError(
7228                        f"{aType!r} action at step {step} has position"
7229                        f" {action[1]!r} which cannot be resolved to a"
7230                        f" decision."
7231                    )
7232                transition, outcomes = action[2]
7233                if (
7234                    action[0] == "explore"
7235                and isinstance(action[3], base.DecisionID)
7236                ):
7237                    destID = action[3]
7238                else:
7239                    destID = graph.getDestination(fromID, transition)
7240                return (fromID, transition, destID)
7241            else:
7242                raise InvalidActionError(
7243                    f"Malformed {aType!r} action:\n{repr(action)}"
7244                )
7245        elif aType == 'warp':
7246            if len(action) != 3:
7247                raise InvalidActionError(
7248                    f"Malformed 'warp' action:\n{repr(action)}"
7249                )
7250            dest = action[2]
7251            assert isinstance(dest, base.DecisionID)
7252            if action[1] in get_args(base.ContextSpecifier):
7253                # Unspecified starting point; find active decisions in
7254                # same domain if primary is None
7255                if primary is not None:
7256                    return (primary, None, dest)
7257                else:
7258                    toDomain = now.graph.domainFor(dest)
7259                    # TODO: Could check destination focalization here...
7260                    active = self.getActiveDecisions(step)
7261                    sameDomain = set(
7262                        dID
7263                        for dID in active
7264                        if now.graph.domainFor(dID) == toDomain
7265                    )
7266                    if len(sameDomain) == 1:
7267                        return (
7268                            list(sameDomain)[0],
7269                            None,
7270                            dest
7271                        )
7272                    else:
7273                        return (
7274                            sameDomain,
7275                            None,
7276                            dest
7277                        )
7278            else:
7279                if (
7280                    not isinstance(action[1], tuple)
7281                or not len(action[1]) == 3
7282                or not action[1][0] in get_args(base.ContextSpecifier)
7283                or not isinstance(action[1][1], base.Domain)
7284                or not isinstance(action[1][2], base.FocalPointName)
7285                ):
7286                    raise InvalidActionError(
7287                        f"Malformed 'warp' action:\n{repr(action)}"
7288                    )
7289                return (
7290                    base.resolvePosition(now, action[1]),
7291                    None,
7292                    dest
7293                )
7294        else:
7295            raise InvalidActionError(
7296                f"Action taken had invalid action type {repr(aType)}:"
7297                f"\n{repr(action)}"
7298            )

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', or 'focalize', 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.

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 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:
7300    def tagStep(
7301        self,
7302        tagOrTags: Union[base.Tag, Dict[base.Tag, base.TagValue]],
7303        tagValue: Union[
7304            base.TagValue,
7305            type[base.NoTagValue]
7306        ] = base.NoTagValue,
7307        step: int = -1
7308    ) -> None:
7309        """
7310        Adds a tag (or multiple tags) to the current step, or to a
7311        specific step if `n` is given as an integer rather than the
7312        default `None`. A tag value should be supplied when a tag is
7313        given (unless you want to use the default of `1`), but it's a
7314        `ValueError` to supply a tag value when a dictionary of tags to
7315        update is provided.
7316        """
7317        if isinstance(tagOrTags, base.Tag):
7318            if tagValue is base.NoTagValue:
7319                tagValue = 1
7320
7321            # Not sure why this is necessary...
7322            tagValue = cast(base.TagValue, tagValue)
7323
7324            self.getSituation(step).tags.update({tagOrTags: tagValue})
7325        else:
7326            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:
7328    def annotateStep(
7329        self,
7330        annotationOrAnnotations: Union[
7331            base.Annotation,
7332            Sequence[base.Annotation]
7333        ],
7334        step: Optional[int] = None
7335    ) -> None:
7336        """
7337        Adds an annotation to the current exploration step, or to a
7338        specific step if `n` is given as an integer rather than the
7339        default `None`.
7340        """
7341        if step is None:
7342            step = -1
7343        if isinstance(annotationOrAnnotations, base.Annotation):
7344            self.getSituation(step).annotations.append(
7345                annotationOrAnnotations
7346            )
7347        else:
7348            self.getSituation(step).annotations.extend(
7349                annotationOrAnnotations
7350            )

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:
7352    def hasCapability(
7353        self,
7354        capability: base.Capability,
7355        step: Optional[int] = None,
7356        inCommon: Union[bool, Literal['both']] = "both"
7357    ) -> bool:
7358        """
7359        Returns True if the player currently had the specified
7360        capability, at the specified exploration step, and False
7361        otherwise. Checks the current state if no step is given. Does
7362        NOT return true if the game state means that the player has an
7363        equivalent for that capability (see
7364        `hasCapabilityOrEquivalent`).
7365
7366        Normally, `inCommon` is set to 'both' by default and so if
7367        either the common `FocalContext` or the active one has the
7368        capability, this will return `True`. `inCommon` may instead be
7369        set to `True` or `False` to ask about just the common (or
7370        active) focal context.
7371        """
7372        state = self.getSituation().state
7373        commonCapabilities = state['common']['capabilities']\
7374            ['capabilities']  # noqa
7375        activeCapabilities = state['contexts'][state['activeContext']]\
7376            ['capabilities']['capabilities']  # noqa
7377
7378        if inCommon == 'both':
7379            return (
7380                capability in commonCapabilities
7381             or capability in activeCapabilities
7382            )
7383        elif inCommon is True:
7384            return capability in commonCapabilities
7385        elif inCommon is False:
7386            return capability in activeCapabilities
7387        else:
7388            raise ValueError(
7389                f"Invalid inCommon value (must be False, True, or"
7390                f" 'both'; got {repr(inCommon)})."
7391            )

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:
7393    def hasCapabilityOrEquivalent(
7394        self,
7395        capability: base.Capability,
7396        step: Optional[int] = None,
7397        location: Optional[Set[base.DecisionID]] = None
7398    ) -> bool:
7399        """
7400        Works like `hasCapability`, but also returns `True` if the
7401        player counts as having the specified capability via an equivalence
7402        that's part of the current graph. As with `hasCapability`, the
7403        optional `step` argument is used to specify which step to check,
7404        with the current step being used as the default.
7405
7406        The `location` set can specify where to start looking for
7407        mechanisms; if left unspecified active decisions for that step
7408        will be used.
7409        """
7410        if step is None:
7411            step = -1
7412        if location is None:
7413            location = self.getActiveDecisions(step)
7414        situation = self.getSituation(step)
7415        return base.hasCapabilityOrEquivalent(
7416            capability,
7417            base.RequirementContext(
7418                state=situation.state,
7419                graph=situation.graph,
7420                searchFrom=location
7421            )
7422        )

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:
7424    def gainCapabilityNow(
7425        self,
7426        capability: base.Capability,
7427        inCommon: bool = False
7428    ) -> None:
7429        """
7430        Modifies the current game state to add the specified `Capability`
7431        to the player's capabilities. No changes are made to the current
7432        graph.
7433
7434        If `inCommon` is set to `True` (default is `False`) then the
7435        capability will be added to the common `FocalContext` and will
7436        therefore persist even when a focal context switch happens.
7437        Normally, it will be added to the currently-active focal
7438        context.
7439        """
7440        state = self.getSituation().state
7441        if inCommon:
7442            context = state['common']
7443        else:
7444            context = state['contexts'][state['activeContext']]
7445        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:
7447    def loseCapabilityNow(
7448        self,
7449        capability: base.Capability,
7450        inCommon: Union[bool, Literal['both']] = "both"
7451    ) -> None:
7452        """
7453        Modifies the current game state to remove the specified `Capability`
7454        from the player's capabilities. Does nothing if the player
7455        doesn't already have that capability.
7456
7457        By default, this removes the capability from both the common
7458        capabilities set and the active `FocalContext`'s capabilities
7459        set, so that afterwards the player will definitely not have that
7460        capability. However, if you set `inCommon` to either `True` or
7461        `False`, it will remove the capability from just the common
7462        capabilities set (if `True`) or just the active capabilities set
7463        (if `False`). In these cases, removing the capability from just
7464        one capability set will not actually remove it in terms of the
7465        `hasCapability` result if it had been present in the other set.
7466        Set `inCommon` to "both" to use the default behavior explicitly.
7467        """
7468        now = self.getSituation()
7469        if inCommon in ("both", True):
7470            context = now.state['common']
7471            try:
7472                context['capabilities']['capabilities'].remove(capability)
7473            except KeyError:
7474                pass
7475        elif inCommon in ("both", False):
7476            context = now.state['contexts'][now.state['activeContext']]
7477            try:
7478                context['capabilities']['capabilities'].remove(capability)
7479            except KeyError:
7480                pass
7481        else:
7482            raise ValueError(
7483                f"Invalid inCommon value (must be False, True, or"
7484                f" 'both'; got {repr(inCommon)})."
7485            )

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]:
7487    def tokenCountNow(self, tokenType: base.Token) -> Optional[int]:
7488        """
7489        Returns the number of tokens the player currently has of a given
7490        type. Returns `None` if the player has never acquired or lost
7491        tokens of that type.
7492
7493        This method adds together tokens from the common and active
7494        focal contexts.
7495        """
7496        state = self.getSituation().state
7497        commonContext = state['common']
7498        activeContext = state['contexts'][state['activeContext']]
7499        base = commonContext['capabilities']['tokens'].get(tokenType)
7500        if base is None:
7501            return activeContext['capabilities']['tokens'].get(tokenType)
7502        else:
7503            return base + activeContext['capabilities']['tokens'].get(
7504                tokenType,
7505                0
7506            )

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:
7508    def adjustTokensNow(
7509        self,
7510        tokenType: base.Token,
7511        amount: int,
7512        inCommon: bool = False
7513    ) -> None:
7514        """
7515        Modifies the current game state to add the specified number of
7516        `Token`s of the given type to the player's tokens. No changes are
7517        made to the current graph. Reduce the number of tokens by
7518        supplying a negative amount; note that negative token amounts
7519        are possible.
7520
7521        By default, the number of tokens for the current active
7522        `FocalContext` will be adjusted. However, if `inCommon` is set
7523        to `True`, then the number of tokens for the common context will
7524        be adjusted instead.
7525        """
7526        # TODO: Custom token caps!
7527        state = self.getSituation().state
7528        if inCommon:
7529            context = state['common']
7530        else:
7531            context = state['contexts'][state['activeContext']]
7532        tokens = context['capabilities']['tokens']
7533        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:
7535    def setTokensNow(
7536        self,
7537        tokenType: base.Token,
7538        amount: int,
7539        inCommon: bool = False
7540    ) -> None:
7541        """
7542        Modifies the current game state to set number of `Token`s of the
7543        given type to a specific amount, regardless of the old value. No
7544        changes are made to the current graph.
7545
7546        By default this sets the number of tokens for the active
7547        `FocalContext`. But if you set `inCommon` to `True`, it will
7548        set the number of tokens in the common context instead.
7549        """
7550        # TODO: Custom token caps!
7551        state = self.getSituation().state
7552        if inCommon:
7553            context = state['common']
7554        else:
7555            context = state['contexts'][state['activeContext']]
7556        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:
7558    def lookupMechanism(
7559        self,
7560        mechanism: base.MechanismName,
7561        step: Optional[int] = None,
7562        where: Union[
7563            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
7564            Collection[base.AnyDecisionSpecifier],
7565            None
7566        ] = None
7567    ) -> base.MechanismID:
7568        """
7569        Looks up a mechanism ID by name, in the graph for the specified
7570        step. The `where` argument specifies where to start looking,
7571        which helps disambiguate. It can be a tuple with a decision
7572        specifier and `None` to start from a single decision, or with a
7573        decision specifier and a transition name to start from either
7574        end of that transition. It can also be `None` to look at global
7575        mechanisms and then all decisions directly, although this
7576        increases the chance of a `MechanismCollisionError`. Finally, it
7577        can be some other non-tuple collection of decision specifiers to
7578        start from that set.
7579
7580        If no step is specified, uses the current step.
7581        """
7582        if step is None:
7583            step = -1
7584        situation = self.getSituation(step)
7585        graph = situation.graph
7586        searchFrom: Collection[base.AnyDecisionSpecifier]
7587        if where is None:
7588            searchFrom = set()
7589        elif isinstance(where, tuple):
7590            if len(where) != 2:
7591                raise ValueError(
7592                    f"Mechanism lookup location was a tuple with an"
7593                    f" invalid length (must be length-2 if it's a"
7594                    f" tuple):\n  {repr(where)}"
7595                )
7596            where = cast(
7597                Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]],
7598                where
7599            )
7600            if where[1] is None:
7601                searchFrom = {graph.resolveDecision(where[0])}
7602            else:
7603                searchFrom = graph.bothEnds(where[0], where[1])
7604        else:  # must be a collection of specifiers
7605            searchFrom = cast(Collection[base.AnyDecisionSpecifier], where)
7606        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]:
7608    def mechanismState(
7609        self,
7610        mechanism: base.AnyMechanismSpecifier,
7611        where: Optional[Set[base.DecisionID]] = None,
7612        step: int = -1
7613    ) -> Optional[base.MechanismState]:
7614        """
7615        Returns the current state for the specified mechanism (or the
7616        state at the specified step if a step index is given). `where`
7617        may be provided as a set of decision IDs to indicate where to
7618        search for the named mechanism, or a mechanism ID may be provided
7619        in the first place. Mechanism states are properties of a `State`
7620        but are not associated with focal contexts.
7621        """
7622        situation = self.getSituation(step)
7623        mID = situation.graph.resolveMechanism(mechanism, startFrom=where)
7624        return situation.state['mechanisms'].get(
7625            mID,
7626            base.DEFAULT_MECHANISM_STATE
7627        )

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:
7629    def setMechanismStateNow(
7630        self,
7631        mechanism: base.AnyMechanismSpecifier,
7632        toState: base.MechanismState,
7633        where: Optional[Set[base.DecisionID]] = None
7634    ) -> None:
7635        """
7636        Sets the state of the specified mechanism to the specified
7637        state. Mechanisms can only be in one state at once, so this
7638        removes any previous states for that mechanism (note that via
7639        equivalences multiple mechanism states can count as active).
7640
7641        The mechanism can be any kind of mechanism specifier (see
7642        `base.AnyMechanismSpecifier`). If it's not a mechanism ID and
7643        doesn't have its own position information, the 'where' argument
7644        can be used to hint where to search for the mechanism.
7645        """
7646        now = self.getSituation()
7647        mID = now.graph.resolveMechanism(mechanism, startFrom=where)
7648        if mID is None:
7649            raise MissingMechanismError(
7650                f"Couldn't find mechanism for {repr(mechanism)}."
7651            )
7652        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]:
7654    def skillLevel(
7655        self,
7656        skill: base.Skill,
7657        step: Optional[int] = None
7658    ) -> Optional[base.Level]:
7659        """
7660        Returns the skill level the player had in a given skill at a
7661        given step, or for the current step if no step is specified.
7662        Returns `None` if the player had never acquired or lost levels
7663        in that skill before the specified step (skill level would count
7664        as 0 in that case).
7665
7666        This method adds together levels from the common and active
7667        focal contexts.
7668        """
7669        if step is None:
7670            step = -1
7671        state = self.getSituation(step).state
7672        commonContext = state['common']
7673        activeContext = state['contexts'][state['activeContext']]
7674        base = commonContext['capabilities']['skills'].get(skill)
7675        if base is None:
7676            return activeContext['capabilities']['skills'].get(skill)
7677        else:
7678            return base + activeContext['capabilities']['skills'].get(
7679                skill,
7680                0
7681            )

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:
7683    def adjustSkillLevelNow(
7684        self,
7685        skill: base.Skill,
7686        levels: base.Level,
7687        inCommon: bool = False
7688    ) -> None:
7689        """
7690        Modifies the current game state to add the specified number of
7691        `Level`s of the given skill. No changes are made to the current
7692        graph. Reduce the skill level by supplying negative levels; note
7693        that negative skill levels are possible.
7694
7695        By default, the skill level for the current active
7696        `FocalContext` will be adjusted. However, if `inCommon` is set
7697        to `True`, then the skill level for the common context will be
7698        adjusted instead.
7699        """
7700        # TODO: Custom level caps?
7701        state = self.getSituation().state
7702        if inCommon:
7703            context = state['common']
7704        else:
7705            context = state['contexts'][state['activeContext']]
7706        skills = context['capabilities']['skills']
7707        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:
7709    def setSkillLevelNow(
7710        self,
7711        skill: base.Skill,
7712        level: base.Level,
7713        inCommon: bool = False
7714    ) -> None:
7715        """
7716        Modifies the current game state to set `Skill` `Level` for the
7717        given skill, regardless of the old value. No changes are made to
7718        the current graph.
7719
7720        By default this sets the skill level for the active
7721        `FocalContext`. But if you set `inCommon` to `True`, it will set
7722        the skill level in the common context instead.
7723        """
7724        # TODO: Custom level caps?
7725        state = self.getSituation().state
7726        if inCommon:
7727            context = state['common']
7728        else:
7729            context = state['contexts'][state['activeContext']]
7730        skills = context['capabilities']['skills']
7731        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:
7733    def updateRequirementNow(
7734        self,
7735        decision: base.AnyDecisionSpecifier,
7736        transition: base.Transition,
7737        requirement: Optional[base.Requirement]
7738    ) -> None:
7739        """
7740        Updates the requirement for a specific transition in a specific
7741        decision. Use `None` to remove the requirement for that edge.
7742        """
7743        if requirement is None:
7744            requirement = base.ReqNothing()
7745        self.getSituation().graph.setTransitionRequirement(
7746            decision,
7747            transition,
7748            requirement
7749        )

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:
7751    def isTraversable(
7752        self,
7753        decision: base.AnyDecisionSpecifier,
7754        transition: base.Transition,
7755        step: int = -1
7756    ) -> bool:
7757        """
7758        Returns True if the specified transition from the specified
7759        decision had its requirement satisfied by the game state at the
7760        specified step (or at the current step if no step is specified).
7761        Raises an `IndexError` if the specified step doesn't exist, and
7762        a `KeyError` if the decision or transition specified does not
7763        exist in the `DecisionGraph` at that step.
7764        """
7765        situation = self.getSituation(step)
7766        req = situation.graph.getTransitionRequirement(decision, transition)
7767        ctx = base.contextForTransition(situation, decision, transition)
7768        fromID = situation.graph.resolveDecision(decision)
7769        return (
7770            req.satisfied(ctx)
7771        and (fromID, transition) not in situation.state['deactivated']
7772        )

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]:
7774    def applyTransitionEffect(
7775        self,
7776        whichEffect: base.EffectSpecifier,
7777        moveWhich: Optional[base.FocalPointName] = None
7778    ) -> Optional[base.DecisionID]:
7779        """
7780        Applies an effect attached to a transition, taking charges and
7781        delay into account based on the current `Situation`.
7782        Modifies the effect's trigger count (but may not actually
7783        trigger the effect if the charges and/or delay values indicate
7784        not to; see `base.doTriggerEffect`).
7785
7786        If a specific focal point in a plural-focalized domain is
7787        triggering the effect, the focal point name should be specified
7788        via `moveWhich` so that goto `Effect`s can know which focal
7789        point to move when it's not explicitly specified in the effect.
7790        TODO: Test this!
7791
7792        Returns None most of the time, but if a 'goto', 'bounce', or
7793        'follow' effect was applied, it returns the decision ID for that
7794        effect's destination, which would override a transition's normal
7795        destination. If it returns a destination ID, then the exploration
7796        state will already have been updated to set the position there,
7797        and further position updates are not needed.
7798
7799        Note that transition effects which update active decisions will
7800        also update the exploration status of those decisions to
7801        'exploring' if they had been in an unvisited status (see
7802        `updatePosition` and `hasBeenVisited`).
7803
7804        Note: callers should immediately update situation-based variables
7805        that might have been changes by a 'revert' effect.
7806        """
7807        now = self.getSituation()
7808        effect, triggerCount = base.doTriggerEffect(now, whichEffect)
7809        if triggerCount is not None:
7810            return self.applyExtraneousEffect(
7811                effect,
7812                where=whichEffect[:2],
7813                moveWhich=moveWhich
7814            )
7815        else:
7816            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]:
7818    def applyExtraneousEffect(
7819        self,
7820        effect: base.Effect,
7821        where: Optional[
7822            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
7823        ] = None,
7824        moveWhich: Optional[base.FocalPointName] = None,
7825        challengePolicy: base.ChallengePolicy = "specified"
7826    ) -> Optional[base.DecisionID]:
7827        """
7828        Applies a single extraneous effect to the state & graph,
7829        *without* accounting for charges or delay values, since the
7830        effect is not part of the graph (use `applyTransitionEffect` to
7831        apply effects that are attached to transitions, which is almost
7832        always the function you should be using). An associated
7833        transition for the extraneous effect can be supplied using the
7834        `where` argument, and effects like 'deactivate' and 'edit' will
7835        affect it (but the effect's charges and delay values will still
7836        be ignored).
7837
7838        If the effect would change the destination of a transition, the
7839        altered destination ID is returned: 'bounce' effects return the
7840        provided decision part of `where`, 'goto' effects return their
7841        target, and 'follow' effects return the destination followed to
7842        (possibly via chained follows in the extreme case). In all other
7843        cases, `None` is returned indicating no change to a normal
7844        destination.
7845
7846        If a specific focal point in a plural-focalized domain is
7847        triggering the effect, the focal point name should be specified
7848        via `moveWhich` so that goto `Effect`s can know which focal
7849        point to move when it's not explicitly specified in the effect.
7850        TODO: Test this!
7851
7852        Note that transition effects which update active decisions will
7853        also update the exploration status of those decisions to
7854        'exploring' if they had been in an unvisited status and will
7855        remove any 'unconfirmed' tag they might still have (see
7856        `updatePosition` and `hasBeenVisited`).
7857
7858        The given `challengePolicy` is applied when traversing further
7859        transitions due to 'follow' effects.
7860
7861        Note: Anyone calling `applyExtraneousEffect` should update any
7862        situation-based variables immediately after the call, as a
7863        'revert' effect may have changed the current graph and/or state.
7864        """
7865        typ = effect['type']
7866        value = effect['value']
7867        applyTo = effect['applyTo']
7868        inCommon = applyTo == 'common'
7869
7870        now = self.getSituation()
7871
7872        if where is not None:
7873            if where[1] is not None:
7874                searchFrom = now.graph.bothEnds(where[0], where[1])
7875            else:
7876                searchFrom = {now.graph.resolveDecision(where[0])}
7877        else:
7878            searchFrom = None
7879
7880        # Note: Delay and charges are ignored!
7881
7882        if typ in ("gain", "lose"):
7883            value = cast(
7884                Union[
7885                    base.Capability,
7886                    Tuple[base.Token, base.TokenCount],
7887                    Tuple[Literal['skill'], base.Skill, base.Level],
7888                ],
7889                value
7890            )
7891            if isinstance(value, base.Capability):
7892                if typ == "gain":
7893                    self.gainCapabilityNow(value, inCommon)
7894                else:
7895                    self.loseCapabilityNow(value, inCommon)
7896            elif len(value) == 2:  # must be a token, amount pair
7897                token, amount = cast(
7898                    Tuple[base.Token, base.TokenCount],
7899                    value
7900                )
7901                if typ == "lose":
7902                    amount *= -1
7903                self.adjustTokensNow(token, amount, inCommon)
7904            else:  # must be a 'skill', skill, level triple
7905                _, skill, levels = cast(
7906                    Tuple[Literal['skill'], base.Skill, base.Level],
7907                    value
7908                )
7909                if typ == "lose":
7910                    levels *= -1
7911                self.adjustSkillLevelNow(skill, levels, inCommon)
7912
7913        elif typ == "set":
7914            value = cast(
7915                Union[
7916                    Tuple[base.Token, base.TokenCount],
7917                    Tuple[base.AnyMechanismSpecifier, base.MechanismState],
7918                    Tuple[Literal['skill'], base.Skill, base.Level],
7919                ],
7920                value
7921            )
7922            if len(value) == 2:  # must be a token or mechanism pair
7923                if isinstance(value[1], base.TokenCount):  # token
7924                    token, amount = cast(
7925                        Tuple[base.Token, base.TokenCount],
7926                        value
7927                    )
7928                    self.setTokensNow(token, amount, inCommon)
7929                else: # mechanism
7930                    mechanism, state = cast(
7931                        Tuple[
7932                            base.AnyMechanismSpecifier,
7933                            base.MechanismState
7934                        ],
7935                        value
7936                    )
7937                    self.setMechanismStateNow(mechanism, state, searchFrom)
7938            else:  # must be a 'skill', skill, level triple
7939                _, skill, level = cast(
7940                    Tuple[Literal['skill'], base.Skill, base.Level],
7941                    value
7942                )
7943                self.setSkillLevelNow(skill, level, inCommon)
7944
7945        elif typ == "toggle":
7946            # Length-1 list just toggles a capability on/off based on current
7947            # state (not attending to equivalents):
7948            if isinstance(value, List):  # capabilities list
7949                value = cast(List[base.Capability], value)
7950                if len(value) == 0:
7951                    raise ValueError(
7952                        "Toggle effect has empty capabilities list."
7953                    )
7954                if len(value) == 1:
7955                    capability = value[0]
7956                    if self.hasCapability(capability, inCommon=False):
7957                        self.loseCapabilityNow(capability, inCommon=False)
7958                    else:
7959                        self.gainCapabilityNow(capability)
7960                else:
7961                    # Otherwise toggle all powers off, then one on,
7962                    # based on the first capability that's currently on.
7963                    # Note we do NOT count equivalences.
7964
7965                    # Find first capability that's on:
7966                    firstIndex: Optional[int] = None
7967                    for i, capability in enumerate(value):
7968                        if self.hasCapability(capability):
7969                            firstIndex = i
7970                            break
7971
7972                    # Turn them all off:
7973                    for capability in value:
7974                        self.loseCapabilityNow(capability, inCommon=False)
7975                        # TODO: inCommon for the check?
7976
7977                    if firstIndex is None:
7978                        self.gainCapabilityNow(value[0])
7979                    else:
7980                        self.gainCapabilityNow(
7981                            value[(firstIndex + 1) % len(value)]
7982                        )
7983            else:  # must be a mechanism w/ states list
7984                mechanism, states = cast(
7985                    Tuple[
7986                        base.AnyMechanismSpecifier,
7987                        List[base.MechanismState]
7988                    ],
7989                    value
7990                )
7991                currentState = self.mechanismState(mechanism, where=searchFrom)
7992                if len(states) == 1:
7993                    if currentState == states[0]:
7994                        # default alternate state
7995                        self.setMechanismStateNow(
7996                            mechanism,
7997                            base.DEFAULT_MECHANISM_STATE,
7998                            searchFrom
7999                        )
8000                    else:
8001                        self.setMechanismStateNow(
8002                            mechanism,
8003                            states[0],
8004                            searchFrom
8005                        )
8006                else:
8007                    # Find our position in the list, if any
8008                    try:
8009                        currentIndex = states.index(cast(str, currentState))
8010                        # Cast here just because we know that None will
8011                        # raise a ValueError but we'll catch it, and we
8012                        # want to suppress the mypy warning about the
8013                        # option
8014                    except ValueError:
8015                        currentIndex = len(states) - 1
8016                    # Set next state in list as current state
8017                    nextIndex = (currentIndex + 1) % len(states)
8018                    self.setMechanismStateNow(
8019                        mechanism,
8020                        states[nextIndex],
8021                        searchFrom
8022                    )
8023
8024        elif typ == "deactivate":
8025            if where is None or where[1] is None:
8026                raise ValueError(
8027                    "Can't apply a deactivate effect without specifying"
8028                    " which transition it applies to."
8029                )
8030
8031            decision, transition = cast(
8032                Tuple[base.AnyDecisionSpecifier, base.Transition],
8033                where
8034            )
8035
8036            dID = now.graph.resolveDecision(decision)
8037            now.state['deactivated'].add((dID, transition))
8038
8039        elif typ == "edit":
8040            value = cast(List[List[commands.Command]], value)
8041            # If there are no blocks, do nothing
8042            if len(value) > 0:
8043                # Apply the first block of commands and then rotate the list
8044                scope: commands.Scope = {}
8045                if where is not None:
8046                    here: base.DecisionID = now.graph.resolveDecision(
8047                        where[0]
8048                    )
8049                    outwards: Optional[base.Transition] = where[1]
8050                    scope['@'] = here
8051                    scope['@t'] = outwards
8052                    if outwards is not None:
8053                        reciprocal = now.graph.getReciprocal(here, outwards)
8054                        destination = now.graph.getDestination(here, outwards)
8055                    else:
8056                        reciprocal = None
8057                        destination = None
8058                    scope['@r'] = reciprocal
8059                    scope['@d'] = destination
8060                self.runCommandBlock(value[0], scope)
8061                value.append(value.pop(0))
8062
8063        elif typ == "goto":
8064            if isinstance(value, base.DecisionSpecifier):
8065                target: base.AnyDecisionSpecifier = value
8066                # use moveWhich provided as argument
8067            elif isinstance(value, tuple):
8068                target, moveWhich = cast(
8069                    Tuple[base.AnyDecisionSpecifier, base.FocalPointName],
8070                    value
8071                )
8072            else:
8073                target = cast(base.AnyDecisionSpecifier, value)
8074                # use moveWhich provided as argument
8075
8076            destID = now.graph.resolveDecision(target)
8077            base.updatePosition(now, destID, applyTo, moveWhich)
8078            return destID
8079
8080        elif typ == "bounce":
8081            # Just need to let the caller know they should cancel
8082            if where is None:
8083                raise ValueError(
8084                    "Can't apply a 'bounce' effect without a position"
8085                    " to apply it from."
8086                )
8087            return now.graph.resolveDecision(where[0])
8088
8089        elif typ == "follow":
8090            if where is None:
8091                raise ValueError(
8092                    f"Can't follow transition {value!r} because there"
8093                    f" is no position information when applying the"
8094                    f" effect."
8095                )
8096            if where[1] is not None:
8097                followFrom = now.graph.getDestination(where[0], where[1])
8098                if followFrom is None:
8099                    raise ValueError(
8100                        f"Can't follow transition {value!r} because the"
8101                        f" position information specifies transition"
8102                        f" {where[1]!r} from decision"
8103                        f" {now.graph.identityOf(where[0])} but that"
8104                        f" transition does not exist."
8105                    )
8106            else:
8107                followFrom = now.graph.resolveDecision(where[0])
8108
8109            following = cast(base.Transition, value)
8110
8111            followTo = now.graph.getDestination(followFrom, following)
8112
8113            if followTo is None:
8114                raise ValueError(
8115                    f"Can't follow transition {following!r} because"
8116                    f" that transition doesn't exist at the specified"
8117                    f" destination {now.graph.identityOf(followFrom)}."
8118                )
8119
8120            if self.isTraversable(followFrom, following):  # skip if not
8121                # Perform initial position update before following new
8122                # transition:
8123                base.updatePosition(
8124                    now,
8125                    followFrom,
8126                    applyTo,
8127                    moveWhich
8128                )
8129
8130                # Apply consequences of followed transition
8131                fullFollowTo = self.applyTransitionConsequence(
8132                    followFrom,
8133                    following,
8134                    moveWhich,
8135                    challengePolicy
8136                )
8137
8138                # Now update to end of followed transition
8139                if fullFollowTo is None:
8140                    base.updatePosition(
8141                        now,
8142                        followTo,
8143                        applyTo,
8144                        moveWhich
8145                    )
8146                    fullFollowTo = followTo
8147
8148                # Skip the normal update: we've taken care of that plus more
8149                return fullFollowTo
8150            else:
8151                # Normal position updates still applies since follow
8152                # transition wasn't possible
8153                return None
8154
8155        elif typ == "save":
8156            assert isinstance(value, base.SaveSlot)
8157            now.saves[value] = copy.deepcopy((now.graph, now.state))
8158
8159        else:
8160            raise ValueError(f"Invalid effect type {typ!r}.")
8161
8162        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]:
8164    def applyExtraneousConsequence(
8165        self,
8166        consequence: base.Consequence,
8167        where: Optional[
8168            Tuple[base.AnyDecisionSpecifier, Optional[base.Transition]]
8169        ] = None,
8170        moveWhich: Optional[base.FocalPointName] = None
8171    ) -> Optional[base.DecisionID]:
8172        """
8173        Applies an extraneous consequence not associated with a
8174        transition. Unlike `applyTransitionConsequence`, the provided
8175        `base.Consequence` must already have observed outcomes (see
8176        `base.observeChallengeOutcomes`). Returns the decision ID for a
8177        decision implied by a goto, follow, or bounce effect, or `None`
8178        if no effect implies a destination.
8179
8180        The `where` and `moveWhich` optional arguments specify which
8181        decision and/or transition to use as the application position,
8182        and/or which focal point to move. This affects mechanism lookup
8183        as well as the end position when 'follow' effects are used.
8184        Specifically:
8185
8186        - A 'follow' trigger will search for transitions to follow from
8187            the destination of the specified transition, or if only a
8188            decision was supplied, from that decision.
8189        - Mechanism lookups will start with both ends of the specified
8190            transition as their search field (or with just the specified
8191            decision if no transition is included).
8192
8193        'bounce' effects will cause an error unless position information
8194        is provided, and will set the position to the base decision
8195        provided in `where`.
8196
8197        Note: callers should update any situation-based variables
8198        immediately after calling this as a 'revert' effect could change
8199        the current graph and/or state and other changes could get lost
8200        if they get applied to a stale graph/state.
8201
8202        # TODO: Examples for goto and follow effects.
8203        """
8204        now = self.getSituation()
8205        searchFrom = set()
8206        if where is not None:
8207            if where[1] is not None:
8208                searchFrom = now.graph.bothEnds(where[0], where[1])
8209            else:
8210                searchFrom = {now.graph.resolveDecision(where[0])}
8211
8212        context = base.RequirementContext(
8213            state=now.state,
8214            graph=now.graph,
8215            searchFrom=searchFrom
8216        )
8217
8218        effectIndices = base.observedEffects(context, consequence)
8219        destID = None
8220        for index in effectIndices:
8221            effect = base.consequencePart(consequence, index)
8222            if not isinstance(effect, dict) or 'value' not in effect:
8223                raise RuntimeError(
8224                    f"Invalid effect index {index}: Consequence part at"
8225                    f" that index is not an Effect. Got:\n{effect}"
8226                )
8227            effect = cast(base.Effect, effect)
8228            destID = self.applyExtraneousEffect(
8229                effect,
8230                where,
8231                moveWhich
8232            )
8233            # technically this variable is not used later in this
8234            # function, but the `applyExtraneousEffect` call means it
8235            # needs an update, so we're doing that in case someone later
8236            # adds code to this function that uses 'now' after this
8237            # point.
8238            now = self.getSituation()
8239
8240        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]:
8242    def applyTransitionConsequence(
8243        self,
8244        decision: base.AnyDecisionSpecifier,
8245        transition: base.AnyTransition,
8246        moveWhich: Optional[base.FocalPointName] = None,
8247        policy: base.ChallengePolicy = "specified",
8248        fromIndex: Optional[int] = None,
8249        toIndex: Optional[int] = None
8250    ) -> Optional[base.DecisionID]:
8251        """
8252        Applies the effects of the specified transition to the current
8253        graph and state, possibly overriding observed outcomes using
8254        outcomes specified as part of a `base.TransitionWithOutcomes`.
8255
8256        The `where` and `moveWhich` function serve the same purpose as
8257        for `applyExtraneousEffect`. If `where` is `None`, then the
8258        effects will be applied as extraneous effects, meaning that
8259        their delay and charges values will be ignored and their trigger
8260        count will not be tracked. If `where` is supplied
8261
8262        Returns either None to indicate that the position update for the
8263        transition should apply as usual, or a decision ID indicating
8264        another destination which has already been applied by a
8265        transition effect.
8266
8267        If `fromIndex` and/or `toIndex` are specified, then only effects
8268        which have indices between those two (inclusive) will be
8269        applied, and other effects will neither apply nor be updated in
8270        any way. Note that `onlyPart` does not override the challenge
8271        policy: if the effects in the specified part are not applied due
8272        to a challenge outcome, they still won't happen, including
8273        challenge outcomes outside of that part. Also, outcomes for
8274        challenges of the entire consequence are re-observed if the
8275        challenge policy implies it.
8276
8277        Note: Anyone calling this should update any situation-based
8278        variables immediately after the call, as a 'revert' effect may
8279        have changed the current graph and/or state.
8280        """
8281        now = self.getSituation()
8282        dID = now.graph.resolveDecision(decision)
8283
8284        transitionName, outcomes = base.nameAndOutcomes(transition)
8285
8286        searchFrom = set()
8287        searchFrom = now.graph.bothEnds(dID, transitionName)
8288
8289        context = base.RequirementContext(
8290            state=now.state,
8291            graph=now.graph,
8292            searchFrom=searchFrom
8293        )
8294
8295        consequence = now.graph.getConsequence(dID, transitionName)
8296
8297        # Make sure that challenge outcomes are known
8298        if policy != "specified":
8299            base.resetChallengeOutcomes(consequence)
8300        useUp = outcomes[:]
8301        base.observeChallengeOutcomes(
8302            context,
8303            consequence,
8304            location=searchFrom,
8305            policy=policy,
8306            knownOutcomes=useUp
8307        )
8308        if len(useUp) > 0:
8309            raise ValueError(
8310                f"More outcomes specified than challenges observed in"
8311                f" consequence:\n{consequence}"
8312                f"\nRemaining outcomes:\n{useUp}"
8313            )
8314
8315        # Figure out which effects apply, and apply each of them
8316        effectIndices = base.observedEffects(context, consequence)
8317        if fromIndex is None:
8318            fromIndex = 0
8319
8320        altDest = None
8321        for index in effectIndices:
8322            if (
8323                index >= fromIndex
8324            and (toIndex is None or index <= toIndex)
8325            ):
8326                thisDest = self.applyTransitionEffect(
8327                    (dID, transitionName, index),
8328                    moveWhich
8329                )
8330                if thisDest is not None:
8331                    altDest = thisDest
8332                # TODO: What if this updates state with 'revert' to a
8333                # graph that doesn't contain the same effects?
8334                # TODO: Update 'now' and 'context'?!
8335        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]:
8337    def allDecisions(self) -> List[base.DecisionID]:
8338        """
8339        Returns the list of all decisions which existed at any point
8340        within the exploration. Example:
8341
8342        >>> ex = DiscreteExploration()
8343        >>> ex.start('A')
8344        0
8345        >>> ex.observe('A', 'right')
8346        1
8347        >>> ex.explore('right', 'B', 'left')
8348        1
8349        >>> ex.observe('B', 'right')
8350        2
8351        >>> ex.allDecisions()  # 'A', 'B', and the unnamed 'right of B'
8352        [0, 1, 2]
8353        """
8354        seen = set()
8355        result = []
8356        for situation in self:
8357            for decision in situation.graph:
8358                if decision not in seen:
8359                    result.append(decision)
8360                    seen.add(decision)
8361
8362        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]:
8364    def allExploredDecisions(self) -> List[base.DecisionID]:
8365        """
8366        Returns the list of all decisions which existed at any point
8367        within the exploration, excluding decisions whose highest
8368        exploration status was `noticed` or lower. May still include
8369        decisions which don't exist in the final situation's graph due to
8370        things like decision merging. Example:
8371
8372        >>> ex = DiscreteExploration()
8373        >>> ex.start('A')
8374        0
8375        >>> ex.observe('A', 'right')
8376        1
8377        >>> ex.explore('right', 'B', 'left')
8378        1
8379        >>> ex.observe('B', 'right')
8380        2
8381        >>> graph = ex.getSituation().graph
8382        >>> graph.addDecision('C')  # add isolated decision; doesn't set status
8383        3
8384        >>> ex.hasBeenVisited('C')
8385        False
8386        >>> ex.allExploredDecisions()
8387        [0, 1]
8388        >>> ex.setExplorationStatus('C', 'exploring')
8389        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
8390        [0, 1, 3]
8391        >>> ex.setExplorationStatus('A', 'explored')
8392        >>> ex.allExploredDecisions()
8393        [0, 1, 3]
8394        >>> ex.setExplorationStatus('A', 'unknown')
8395        >>> # remains visisted in an earlier step
8396        >>> ex.allExploredDecisions()
8397        [0, 1, 3]
8398        >>> ex.setExplorationStatus('C', 'unknown')  # not explored earlier
8399        >>> ex.allExploredDecisions()  # 2 is the decision right from 'B'
8400        [0, 1]
8401        """
8402        seen = set()
8403        result = []
8404        for situation in self:
8405            graph = situation.graph
8406            for decision in graph:
8407                if (
8408                    decision not in seen
8409                and base.hasBeenVisited(situation, decision)
8410                ):
8411                    result.append(decision)
8412                    seen.add(decision)
8413
8414        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]:
8416    def allVisitedDecisions(self) -> List[base.DecisionID]:
8417        """
8418        Returns the list of all decisions which existed at any point
8419        within the exploration and which were visited at least once.
8420        Usually all of these will be present in the final situation's
8421        graph, but sometimes merging or other factors means there might
8422        be some that won't be. Being present on the game state's 'active'
8423        list in a step for its domain is what counts as "being visited,"
8424        which means that nodes which were passed through directly via a
8425        'follow' effect won't be counted, for example.
8426
8427        This should usually correspond with the absence of the
8428        'unconfirmed' tag.
8429
8430        Example:
8431
8432        >>> ex = DiscreteExploration()
8433        >>> ex.start('A')
8434        0
8435        >>> ex.observe('A', 'right')
8436        1
8437        >>> ex.explore('right', 'B', 'left')
8438        1
8439        >>> ex.observe('B', 'right')
8440        2
8441        >>> ex.getSituation().graph.addDecision('C')  # add isolated decision
8442        3
8443        >>> av = ex.allVisitedDecisions()
8444        >>> av
8445        [0, 1]
8446        >>> all(  # no decisions in the 'visited' list are tagged
8447        ...     'unconfirmed' not in ex.getSituation().graph.decisionTags(d)
8448        ...     for d in av
8449        ... )
8450        True
8451        >>> graph = ex.getSituation().graph
8452        >>> 'unconfirmed' in graph.decisionTags(0)
8453        False
8454        >>> 'unconfirmed' in graph.decisionTags(1)
8455        False
8456        >>> 'unconfirmed' in graph.decisionTags(2)
8457        True
8458        >>> 'unconfirmed' in graph.decisionTags(3)  # not tagged; not explored
8459        False
8460        """
8461        seen = set()
8462        result = []
8463        for step in range(len(self)):
8464            active = self.getActiveDecisions(step)
8465            for dID in active:
8466                if dID not in seen:
8467                    result.append(dID)
8468                    seen.add(dID)
8469
8470        return result

Returns the list of all decisions which existed at any point within the exploration and which were visited at least once. Usually all of these 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 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:
8472    def start(
8473        self,
8474        decision: base.AnyDecisionSpecifier,
8475        startCapabilities: Optional[base.CapabilitySet] = None,
8476        setMechanismStates: Optional[
8477            Dict[base.MechanismID, base.MechanismState]
8478        ] = None,
8479        setCustomState: Optional[dict] = None,
8480        decisionType: base.DecisionType = "imposed"
8481    ) -> base.DecisionID:
8482        """
8483        Sets the initial position information for a newly-relevant
8484        domain for the current focal context. Creates a new decision
8485        if the decision is specified by name or `DecisionSpecifier` and
8486        that decision doesn't already exist. Returns the decision ID for
8487        the newly-placed decision (or for the specified decision if it
8488        already existed).
8489
8490        Raises a `BadStart` error if the current focal context already
8491        has position information for the specified domain.
8492
8493        - The given `startCapabilities` replaces any existing
8494            capabilities for the current focal context, although you can
8495            leave it as the default `None` to avoid that and retain any
8496            capabilities that have been set up already.
8497        - The given `setMechanismStates` and `setCustomState`
8498            dictionaries override all previous mechanism states & custom
8499            states in the new situation. Leave these as the default
8500            `None` to maintain those states.
8501        - If created, the decision will be placed in the DEFAULT_DOMAIN
8502            domain unless it's specified as a `base.DecisionSpecifier`
8503            with a domain part, in which case that domain is used.
8504        - If specified as a `base.DecisionSpecifier` with a zone part
8505            and a new decision needs to be created, the decision will be
8506            added to that zone, creating it at level 0 if necessary,
8507            although otherwise no zone information will be changed.
8508        - Resets the decision type to "pending" and the action taken to
8509            `None`. Sets the decision type of the previous situation to
8510            'imposed' (or the specified `decisionType`) and sets an
8511            appropriate 'start' action for that situation.
8512        - Tags the step with 'start'.
8513        - Even in a plural- or spreading-focalized domain, you still need
8514            to pick one decision to start at.
8515        """
8516        now = self.getSituation()
8517
8518        startID = now.graph.getDecision(decision)
8519        zone = None
8520        domain = base.DEFAULT_DOMAIN
8521        if startID is None:
8522            if isinstance(decision, base.DecisionID):
8523                raise MissingDecisionError(
8524                    f"Cannot start at decision {decision} because no"
8525                    f" decision with that ID exists. Supply a name or"
8526                    f" DecisionSpecifier if you need the start decision"
8527                    f" to be created automatically."
8528                )
8529            elif isinstance(decision, base.DecisionName):
8530                decision = base.DecisionSpecifier(
8531                    domain=None,
8532                    zone=None,
8533                    name=decision
8534                )
8535            startID = now.graph.addDecision(
8536                decision.name,
8537                domain=decision.domain
8538            )
8539            zone = decision.zone
8540            if decision.domain is not None:
8541                domain = decision.domain
8542
8543        if zone is not None:
8544            if now.graph.getZoneInfo(zone) is None:
8545                now.graph.createZone(zone, 0)
8546            now.graph.addDecisionToZone(startID, zone)
8547
8548        action: base.ExplorationAction = (
8549            'start',
8550            startID,
8551            startID,
8552            domain,
8553            startCapabilities,
8554            setMechanismStates,
8555            setCustomState
8556        )
8557
8558        self.advanceSituation(action, decisionType)
8559
8560        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):
8562    def hasBeenVisited(
8563        self,
8564        decision: base.AnyDecisionSpecifier,
8565        step: int = -1
8566    ):
8567        """
8568        Returns whether or not the specified decision has been visited in
8569        the specified step (default current step).
8570        """
8571        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):
8573    def setExplorationStatus(
8574        self,
8575        decision: base.AnyDecisionSpecifier,
8576        status: base.ExplorationStatus,
8577        upgradeOnly: bool = False
8578    ):
8579        """
8580        Updates the current exploration status of a specific decision in
8581        the current situation. If `upgradeOnly` is true (default is
8582        `False` then the update will only apply if the new exploration
8583        status counts as 'more-explored' than the old one (see
8584        `base.moreExplored`).
8585        """
8586        base.setExplorationStatus(
8587            self.getSituation(),
8588            decision,
8589            status,
8590            upgradeOnly
8591        )

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):
8593    def getExplorationStatus(
8594        self,
8595        decision: base.AnyDecisionSpecifier,
8596        step: int = -1
8597    ):
8598        """
8599        Returns the exploration status of the specified decision at the
8600        specified step (default is last step). Decisions whose
8601        exploration status has never been set will have a default status
8602        of 'unknown'.
8603        """
8604        situation = self.getSituation(step)
8605        dID = situation.graph.resolveDecision(decision)
8606        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]]]:
8608    def deduceTransitionDetailsAtStep(
8609        self,
8610        step: int,
8611        transition: base.Transition,
8612        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
8613        whichFocus: Optional[base.FocalPointSpecifier] = None,
8614        inCommon: Union[bool, Literal["auto"]] = "auto"
8615    ) -> Tuple[
8616        base.ContextSpecifier,
8617        base.DecisionID,
8618        base.DecisionID,
8619        Optional[base.FocalPointSpecifier]
8620    ]:
8621        """
8622        Given just a transition name which the player intends to take in
8623        a specific step, deduces the `ContextSpecifier` for which
8624        context should be updated, the source and destination
8625        `DecisionID`s for the transition, and if the destination
8626        decision's domain is plural-focalized, the `FocalPointName`
8627        specifying which focal point should be moved.
8628
8629        Because many of those things are ambiguous, you may get an
8630        `AmbiguousTransitionError` when things are underspecified, and
8631        there are options for specifying some of the extra information
8632        directly:
8633
8634        - `fromDecision` may be used to specify the source decision.
8635        - `whichFocus` may be used to specify the focal point (within a
8636            particular context/domain) being updated. When focal point
8637            ambiguity remains and this is unspecified, the
8638            alphabetically-earliest relevant focal point will be used
8639            (either among all focal points which activate the source
8640            decision, if there are any, or among all focal points for
8641            the entire domain of the destination decision).
8642        - `inCommon` (a `ContextSpecifier`) may be used to specify which
8643            context to update. The default of "auto" will cause the
8644            active context to be selected unless it does not activate
8645            the source decision, in which case the common context will
8646            be selected.
8647
8648        A `MissingDecisionError` will be raised if there are no current
8649        active decisions (e.g., before `start` has been called), and a
8650        `MissingTransitionError` will be raised if the listed transition
8651        does not exist from any active decision (or from the specified
8652        decision if `fromDecision` is used).
8653        """
8654        now = self.getSituation(step)
8655        active = self.getActiveDecisions(step)
8656        if len(active) == 0:
8657            raise MissingDecisionError(
8658                f"There are no active decisions from which transition"
8659                f" {repr(transition)} could be taken at step {step}."
8660            )
8661
8662        # All source/destination decision pairs for transitions with the
8663        # given transition name.
8664        allDecisionPairs: Dict[base.DecisionID, base.DecisionID] = {}
8665
8666        # TODO: When should we be trimming the active decisions to match
8667        # any alterations to the graph?
8668        for dID in active:
8669            outgoing = now.graph.destinationsFrom(dID)
8670            if transition in outgoing:
8671                allDecisionPairs[dID] = outgoing[transition]
8672
8673        if len(allDecisionPairs) == 0:
8674            raise MissingTransitionError(
8675                f"No transitions named {repr(transition)} are outgoing"
8676                f" from active decisions at step {step}."
8677                f"\nActive decisions are:"
8678                f"\n{now.graph.namesListing(active)}"
8679            )
8680
8681        if (
8682            fromDecision is not None
8683        and fromDecision not in allDecisionPairs
8684        ):
8685            raise MissingTransitionError(
8686                f"{fromDecision} was specified as the source decision"
8687                f" for traversing transition {repr(transition)} but"
8688                f" there is no transition of that name from that"
8689                f" decision at step {step}."
8690                f"\nValid source decisions are:"
8691                f"\n{now.graph.namesListing(allDecisionPairs)}"
8692            )
8693        elif fromDecision is not None:
8694            fromID = now.graph.resolveDecision(fromDecision)
8695            destID = allDecisionPairs[fromID]
8696            fromDomain = now.graph.domainFor(fromID)
8697        elif len(allDecisionPairs) == 1:
8698            fromID, destID = list(allDecisionPairs.items())[0]
8699            fromDomain = now.graph.domainFor(fromID)
8700        else:
8701            fromID = None
8702            destID = None
8703            fromDomain = None
8704            # Still ambiguous; resolve this below
8705
8706        # Use whichFocus if provided
8707        if whichFocus is not None:
8708            # Type/value check for whichFocus
8709            if (
8710                not isinstance(whichFocus, tuple)
8711             or len(whichFocus) != 3
8712             or whichFocus[0] not in ("active", "common")
8713             or not isinstance(whichFocus[1], base.Domain)
8714             or not isinstance(whichFocus[2], base.FocalPointName)
8715            ):
8716                raise ValueError(
8717                    f"Invalid whichFocus value {repr(whichFocus)}."
8718                    f"\nMust be a length-3 tuple with 'active' or 'common'"
8719                    f" as the first element, a Domain as the second"
8720                    f" element, and a FocalPointName as the third"
8721                    f" element."
8722                )
8723
8724            # Resolve focal point specified
8725            fromID = base.resolvePosition(
8726                now,
8727                whichFocus
8728            )
8729            if fromID is None:
8730                raise MissingTransitionError(
8731                    f"Focal point {repr(whichFocus)} was specified as"
8732                    f" the transition source, but that focal point does"
8733                    f" not have a position."
8734                )
8735            else:
8736                destID = now.graph.destination(fromID, transition)
8737                fromDomain = now.graph.domainFor(fromID)
8738
8739        elif fromID is None:  # whichFocus is None, so it can't disambiguate
8740            raise AmbiguousTransitionError(
8741                f"Transition {repr(transition)} was selected for"
8742                f" disambiguation, but there are multiple transitions"
8743                f" with that name from currently-active decisions, and"
8744                f" neither fromDecision nor whichFocus adequately"
8745                f" disambiguates the specific transition taken."
8746                f"\nValid source decisions at step {step} are:"
8747                f"\n{now.graph.namesListing(allDecisionPairs)}"
8748            )
8749
8750        # At this point, fromID, destID, and fromDomain have
8751        # been resolved.
8752        if fromID is None or destID is None or fromDomain is None:
8753            raise RuntimeError(
8754                f"One of fromID, destID, or fromDomain was None after"
8755                f" disambiguation was finished:"
8756                f"\nfromID: {fromID}, destID: {destID}, fromDomain:"
8757                f" {repr(fromDomain)}"
8758            )
8759
8760        # Now figure out which context activated the source so we know
8761        # which focal point we're moving:
8762        context = self.getActiveContext()
8763        active = base.activeDecisionSet(context)
8764        using: base.ContextSpecifier = "active"
8765        if fromID not in active:
8766            context = self.getCommonContext(step)
8767            using = "common"
8768
8769        destDomain = now.graph.domainFor(destID)
8770        if (
8771            whichFocus is None
8772        and base.getDomainFocalization(context, destDomain) == 'plural'
8773        ):
8774            # Need to figure out which focal point is moving; use the
8775            # alphabetically earliest one that's positioned at the
8776            # fromID, or just the earliest one overall if none of them
8777            # are there.
8778            contextFocalPoints: Dict[
8779                base.FocalPointName,
8780                Optional[base.DecisionID]
8781            ] = cast(
8782                Dict[base.FocalPointName, Optional[base.DecisionID]],
8783                context['activeDecisions'][destDomain]
8784            )
8785            if not isinstance(contextFocalPoints, dict):
8786                raise RuntimeError(
8787                    f"Active decisions specifier for domain"
8788                    f" {repr(destDomain)} with plural focalization has"
8789                    f" a non-dictionary value."
8790                )
8791
8792            if fromDomain == destDomain:
8793                focalCandidates = [
8794                    fp
8795                    for fp, pos in contextFocalPoints.items()
8796                    if pos == fromID
8797                ]
8798            else:
8799                focalCandidates = list(contextFocalPoints)
8800
8801            whichFocus = (using, destDomain, min(focalCandidates))
8802
8803        # Now whichFocus has been set if it wasn't already specified;
8804        # might still be None if it's not relevant.
8805        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], Union[str, NoneType, type[exploration.base.DefaultZone]]], Tuple[Literal['explore'], Tuple[Literal['common', 'active'], str, str], Tuple[str, List[bool]], Union[str, int, NoneType], Optional[str], Union[str, NoneType, type[exploration.base.DefaultZone]]], 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]]:
8807    def advanceSituation(
8808        self,
8809        action: base.ExplorationAction,
8810        decisionType: base.DecisionType = "active",
8811        challengePolicy: base.ChallengePolicy = "specified"
8812    ) -> Tuple[base.Situation, Set[base.DecisionID]]:
8813        """
8814        Given an `ExplorationAction`, sets that as the action taken in
8815        the current situation, and adds a new situation with the results
8816        of that action. A `DoubleActionError` will be raised if the
8817        current situation already has an action specified, and/or has a
8818        decision type other than 'pending'. By default the type of the
8819        decision will be 'active' but another `DecisionType` can be
8820        specified via the `decisionType` parameter.
8821
8822        If the action specified is `('noAction',)`, then the new
8823        situation will be a copy of the old one; this represents waiting
8824        or being at an ending (a decision type other than 'pending'
8825        should be used).
8826
8827        Although `None` can appear as the action entry in situations
8828        with pending decisions, you cannot call `advanceSituation` with
8829        `None` as the action.
8830
8831        If the action includes taking a transition whose requirements
8832        are not satisfied, the transition will still be taken (and any
8833        consequences applied) but a `TransitionBlockedWarning` will be
8834        issued.
8835
8836        A `ChallengePolicy` may be specified, the default is 'specified'
8837        which requires that outcomes are pre-specified. If any other
8838        policy is set, the challenge outcomes will be reset before
8839        re-resolving them according to the provided policy.
8840
8841        The new situation will have decision type 'pending' and `None`
8842        as the action.
8843
8844        The new situation created as a result of the action is returned,
8845        along with the set of destination decision IDs, including
8846        possibly a modified destination via 'bounce', 'goto', and/or
8847        'follow' effects. For actions that don't have a destination, the
8848        second part of the returned tuple will be an empty set. Multiple
8849        IDs may be in the set when using a start action in a plural- or
8850        spreading-focalized domain, for example.
8851
8852        If the action updates active decisions (including via transition
8853        effects) this will also update the exploration status of those
8854        decisions to 'exploring' if they had been in an unvisited
8855        status (see `updatePosition` and `hasBeenVisited`). This
8856        includes decisions traveled through but not ultimately arrived
8857        at via 'follow' effects.
8858
8859        If any decisions are active in the `ENDINGS_DOMAIN`, attempting
8860        to 'warp', 'explore', 'take', or 'start' will raise an
8861        `InvalidActionError`.
8862        """
8863        now = self.getSituation()
8864        if now.type != 'pending' or now.action is not None:
8865            raise DoubleActionError(
8866                f"Attempted to take action {repr(action)} at step"
8867                f" {len(self) - 1}, but an action and/or decision type"
8868                f" had already been specified:"
8869                f"\nAction: {repr(now.action)}"
8870                f"\nType: {repr(now.type)}"
8871            )
8872
8873        # Update the now situation to add in the decision type and
8874        # action taken:
8875        revised = base.Situation(
8876            now.graph,
8877            now.state,
8878            decisionType,
8879            action,
8880            now.saves,
8881            now.tags,
8882            now.annotations
8883        )
8884        self.situations[-1] = revised
8885
8886        # Separate update process when reverting (this branch returns)
8887        if (
8888            action is not None
8889        and isinstance(action, tuple)
8890        and len(action) == 3
8891        and action[0] == 'revertTo'
8892        and isinstance(action[1], base.SaveSlot)
8893        and isinstance(action[2], set)
8894        and all(isinstance(x, str) for x in action[2])
8895        ):
8896            _, slot, aspects = action
8897            if slot not in now.saves:
8898                raise KeyError(
8899                    f"Cannot load save slot {slot!r} because no save"
8900                    f" data has been established for that slot."
8901                )
8902            load = now.saves[slot]
8903            rGraph, rState = base.revertedState(
8904                (now.graph, now.state),
8905                load,
8906                aspects
8907            )
8908            reverted = base.Situation(
8909                graph=rGraph,
8910                state=rState,
8911                type='pending',
8912                action=None,
8913                saves=copy.deepcopy(now.saves),
8914                tags={},
8915                annotations=[]
8916            )
8917            self.situations.append(reverted)
8918            # Apply any active triggers (edits reverted)
8919            self.applyActiveTriggers()
8920            # Figure out destinations set to return
8921            newDestinations = set()
8922            newPr = rState['primaryDecision']
8923            if newPr is not None:
8924                newDestinations.add(newPr)
8925            return (reverted, newDestinations)
8926
8927        # TODO: These deep copies are expensive time-wise. Can we avoid
8928        # them? Probably not.
8929        newGraph = copy.deepcopy(now.graph)
8930        newState = copy.deepcopy(now.state)
8931        newSaves = copy.copy(now.saves)  # a shallow copy
8932        newTags: Dict[base.Tag, base.TagValue] = {}
8933        newAnnotations: List[base.Annotation] = []
8934        updated = base.Situation(
8935            graph=newGraph,
8936            state=newState,
8937            type='pending',
8938            action=None,
8939            saves=newSaves,
8940            tags=newTags,
8941            annotations=newAnnotations
8942        )
8943
8944        targetContext: base.FocalContext
8945
8946        # Now that action effects have been imprinted into the updated
8947        # situation, append it to our situations list
8948        self.situations.append(updated)
8949
8950        # Figure out effects of the action:
8951        if action is None:
8952            raise InvalidActionError(
8953                "None cannot be used as an action when advancing the"
8954                " situation."
8955            )
8956
8957        aLen = len(action)
8958
8959        destIDs = set()
8960
8961        if (
8962            action[0] in ('start', 'take', 'explore', 'warp')
8963        and any(
8964                newGraph.domainFor(d) == ENDINGS_DOMAIN
8965                for d in self.getActiveDecisions()
8966            )
8967        ):
8968            activeEndings = [
8969                d
8970                for d in self.getActiveDecisions()
8971                if newGraph.domainFor(d) == ENDINGS_DOMAIN
8972            ]
8973            raise InvalidActionError(
8974                f"Attempted to {action[0]!r} while an ending was"
8975                f" active. Active endings are:"
8976                f"\n{newGraph.namesListing(activeEndings)}"
8977            )
8978
8979        if action == ('noAction',):
8980            # No updates needed
8981            pass
8982
8983        elif (
8984            not isinstance(action, tuple)
8985         or (action[0] not in get_args(base.ExplorationActionType))
8986         or not (2 <= aLen <= 7)
8987        ):
8988            raise InvalidActionError(
8989                f"Invalid ExplorationAction tuple (must be a tuple that"
8990                f" starts with an ExplorationActionType and has 2-6"
8991                f" entries if it's not ('noAction',)):"
8992                f"\n{repr(action)}"
8993            )
8994
8995        elif action[0] == 'start':
8996            (
8997                _,
8998                positionSpecifier,
8999                primary,
9000                domain,
9001                capabilities,
9002                mechanismStates,
9003                customState
9004            ) = cast(
9005                Tuple[
9006                    Literal['start'],
9007                    Union[
9008                        base.DecisionID,
9009                        Dict[base.FocalPointName, base.DecisionID],
9010                        Set[base.DecisionID]
9011                    ],
9012                    Optional[base.DecisionID],
9013                    base.Domain,
9014                    Optional[base.CapabilitySet],
9015                    Optional[Dict[base.MechanismID, base.MechanismState]],
9016                    Optional[dict]
9017                ],
9018                action
9019            )
9020            targetContext = newState['contexts'][
9021                newState['activeContext']
9022            ]
9023
9024            targetFocalization = base.getDomainFocalization(
9025                targetContext,
9026                domain
9027            )  # sets up 'singular' as default if
9028
9029            # Check if there are any already-active decisions.
9030            if targetContext['activeDecisions'][domain] is not None:
9031                raise BadStart(
9032                    f"Cannot start in domain {repr(domain)} because"
9033                    f" that domain already has a position. 'start' may"
9034                    f" only be used with domains that don't yet have"
9035                    f" any position information."
9036                )
9037
9038            # Make the domain active
9039            if domain not in targetContext['activeDomains']:
9040                targetContext['activeDomains'].add(domain)
9041
9042            # Check position info matches focalization type and update
9043            # exploration statuses
9044            if isinstance(positionSpecifier, base.DecisionID):
9045                if targetFocalization != 'singular':
9046                    raise BadStart(
9047                        f"Invalid position specifier"
9048                        f" {repr(positionSpecifier)} (type"
9049                        f" {type(positionSpecifier)}). Domain"
9050                        f" {repr(domain)} has {targetFocalization}"
9051                        f" focalization."
9052                    )
9053                base.setExplorationStatus(
9054                    updated,
9055                    positionSpecifier,
9056                    'exploring',
9057                    upgradeOnly=True
9058                )
9059                destIDs.add(positionSpecifier)
9060            elif isinstance(positionSpecifier, dict):
9061                if targetFocalization != 'plural':
9062                    raise BadStart(
9063                        f"Invalid position specifier"
9064                        f" {repr(positionSpecifier)} (type"
9065                        f" {type(positionSpecifier)}). Domain"
9066                        f" {repr(domain)} has {targetFocalization}"
9067                        f" focalization."
9068                    )
9069                destIDs |= set(positionSpecifier.values())
9070            elif isinstance(positionSpecifier, set):
9071                if targetFocalization != 'spreading':
9072                    raise BadStart(
9073                        f"Invalid position specifier"
9074                        f" {repr(positionSpecifier)} (type"
9075                        f" {type(positionSpecifier)}). Domain"
9076                        f" {repr(domain)} has {targetFocalization}"
9077                        f" focalization."
9078                    )
9079                destIDs |= positionSpecifier
9080            else:
9081                raise TypeError(
9082                    f"Invalid position specifier"
9083                    f" {repr(positionSpecifier)} (type"
9084                    f" {type(positionSpecifier)}). It must be a"
9085                    f" DecisionID, a dictionary from FocalPointNames to"
9086                    f" DecisionIDs, or a set of DecisionIDs, according"
9087                    f" to the focalization of the relevant domain."
9088                )
9089
9090            # Put specified position(s) in place
9091            # TODO: This cast is really silly...
9092            targetContext['activeDecisions'][domain] = cast(
9093                Union[
9094                    None,
9095                    base.DecisionID,
9096                    Dict[base.FocalPointName, Optional[base.DecisionID]],
9097                    Set[base.DecisionID]
9098                ],
9099                positionSpecifier
9100            )
9101
9102            # Set primary decision
9103            newState['primaryDecision'] = primary
9104
9105            # Set capabilities
9106            if capabilities is not None:
9107                targetContext['capabilities'] = capabilities
9108
9109            # Set mechanism states
9110            if mechanismStates is not None:
9111                newState['mechanisms'] = mechanismStates
9112
9113            # Set custom state
9114            if customState is not None:
9115                newState['custom'] = customState
9116
9117        elif action[0] in ('explore', 'take', 'warp'):  # similar handling
9118            assert (
9119                len(action) == 3
9120             or len(action) == 4
9121             or len(action) == 6
9122             or len(action) == 7
9123            )
9124            # Set up necessary variables
9125            cSpec: base.ContextSpecifier = "active"
9126            fromID: Optional[base.DecisionID] = None
9127            takeTransition: Optional[base.Transition] = None
9128            outcomes: List[bool] = []
9129            destID: base.DecisionID  # No starting value as it's not optional
9130            moveInDomain: Optional[base.Domain] = None
9131            moveWhich: Optional[base.FocalPointName] = None
9132
9133            # Figure out target context
9134            if isinstance(action[1], str):
9135                if action[1] not in get_args(base.ContextSpecifier):
9136                    raise InvalidActionError(
9137                        f"Action specifies {repr(action[1])} context,"
9138                        f" but that's not a valid context specifier."
9139                        f" The valid options are:"
9140                        f"\n{repr(get_args(base.ContextSpecifier))}"
9141                    )
9142                else:
9143                    cSpec = cast(base.ContextSpecifier, action[1])
9144            else:  # Must be a `FocalPointSpecifier`
9145                cSpec, moveInDomain, moveWhich = cast(
9146                    base.FocalPointSpecifier,
9147                    action[1]
9148                )
9149                assert moveInDomain is not None
9150
9151            # Grab target context to work in
9152            if cSpec == 'common':
9153                targetContext = newState['common']
9154            else:
9155                targetContext = newState['contexts'][
9156                    newState['activeContext']
9157                ]
9158
9159            # Check focalization of the target domain
9160            if moveInDomain is not None:
9161                fType = base.getDomainFocalization(
9162                    targetContext,
9163                    moveInDomain
9164                )
9165                if (
9166                    (
9167                        isinstance(action[1], str)
9168                    and fType == 'plural'
9169                    ) or (
9170                        not isinstance(action[1], str)
9171                    and fType != 'plural'
9172                    )
9173                ):
9174                    raise ImpossibleActionError(
9175                        f"Invalid ExplorationAction (moves in"
9176                        f" plural-focalized domains must include a"
9177                        f" FocalPointSpecifier, while moves in"
9178                        f" non-plural-focalized domains must not."
9179                        f" Domain {repr(moveInDomain)} is"
9180                        f" {fType}-focalized):"
9181                        f"\n{repr(action)}"
9182                    )
9183
9184            if action[0] == "warp":
9185                # It's a warp, so destination is specified directly
9186                if not isinstance(action[2], base.DecisionID):
9187                    raise TypeError(
9188                        f"Invalid ExplorationAction tuple (third part"
9189                        f" must be a decision ID for 'warp' actions):"
9190                        f"\n{repr(action)}"
9191                    )
9192                else:
9193                    destID = cast(base.DecisionID, action[2])
9194
9195            elif aLen == 4 or aLen == 7:
9196                # direct 'take' or 'explore'
9197                fromID = cast(base.DecisionID, action[2])
9198                takeTransition, outcomes = cast(
9199                    base.TransitionWithOutcomes,
9200                    action[3]  # type: ignore [misc]
9201                )
9202                if (
9203                    not isinstance(fromID, base.DecisionID)
9204                 or not isinstance(takeTransition, base.Transition)
9205                ):
9206                    raise InvalidActionError(
9207                        f"Invalid ExplorationAction tuple (for 'take' or"
9208                        f" 'explore', if the length is 4/7, parts 2-4"
9209                        f" must be a context specifier, a decision ID, and a"
9210                        f" transition name. Got:"
9211                        f"\n{repr(action)}"
9212                    )
9213
9214                try:
9215                    destID = newGraph.destination(fromID, takeTransition)
9216                except MissingDecisionError:
9217                    raise ImpossibleActionError(
9218                        f"Invalid ExplorationAction: move from decision"
9219                        f" {fromID} is invalid because there is no"
9220                        f" decision with that ID in the current"
9221                        f" graph."
9222                        f"\nValid decisions are:"
9223                        f"\n{newGraph.namesListing(newGraph)}"
9224                    )
9225                except MissingTransitionError:
9226                    valid = newGraph.destinationsFrom(fromID)
9227                    listing = newGraph.destinationsListing(valid)
9228                    raise ImpossibleActionError(
9229                        f"Invalid ExplorationAction: move from decision"
9230                        f" {newGraph.identityOf(fromID)}"
9231                        f" along transition {repr(takeTransition)} is"
9232                        f" invalid because there is no such transition"
9233                        f" at that decision."
9234                        f"\nValid transitions there are:"
9235                        f"\n{listing}"
9236                    )
9237                targetActive = targetContext['activeDecisions']
9238                if moveInDomain is not None:
9239                    activeInDomain = targetActive[moveInDomain]
9240                    if (
9241                        (
9242                            isinstance(activeInDomain, base.DecisionID)
9243                        and fromID != activeInDomain
9244                        )
9245                     or (
9246                            isinstance(activeInDomain, set)
9247                        and fromID not in activeInDomain
9248                        )
9249                     or (
9250                            isinstance(activeInDomain, dict)
9251                        and fromID not in activeInDomain.values()
9252                        )
9253                    ):
9254                        raise ImpossibleActionError(
9255                            f"Invalid ExplorationAction: move from"
9256                            f" decision {fromID} is invalid because"
9257                            f" that decision is not active in domain"
9258                            f" {repr(moveInDomain)} in the current"
9259                            f" graph."
9260                            f"\nValid decisions are:"
9261                            f"\n{newGraph.namesListing(newGraph)}"
9262                        )
9263
9264            elif aLen == 3 or aLen == 6:
9265                # 'take' or 'explore' focal point
9266                # We know that moveInDomain is not None here.
9267                assert moveInDomain is not None
9268                if not isinstance(action[2], base.Transition):
9269                    raise InvalidActionError(
9270                        f"Invalid ExplorationAction tuple (for 'take'"
9271                        f" actions if the second part is a"
9272                        f" FocalPointSpecifier the third part must be a"
9273                        f" transition name):"
9274                        f"\n{repr(action)}"
9275                    )
9276
9277                takeTransition, outcomes = cast(
9278                    base.TransitionWithOutcomes,
9279                    action[2]
9280                )
9281                targetActive = targetContext['activeDecisions']
9282                activeInDomain = cast(
9283                    Dict[base.FocalPointName, Optional[base.DecisionID]],
9284                    targetActive[moveInDomain]
9285                )
9286                if (
9287                    moveInDomain is not None
9288                and (
9289                        not isinstance(activeInDomain, dict)
9290                     or moveWhich not in activeInDomain
9291                    )
9292                ):
9293                    raise ImpossibleActionError(
9294                        f"Invalid ExplorationAction: move of focal"
9295                        f" point {repr(moveWhich)} in domain"
9296                        f" {repr(moveInDomain)} is invalid because"
9297                        f" that domain does not have a focal point"
9298                        f" with that name."
9299                    )
9300                fromID = activeInDomain[moveWhich]
9301                if fromID is None:
9302                    raise ImpossibleActionError(
9303                        f"Invalid ExplorationAction: move of focal"
9304                        f" point {repr(moveWhich)} in domain"
9305                        f" {repr(moveInDomain)} is invalid because"
9306                        f" that focal point does not have a position"
9307                        f" at this step."
9308                    )
9309                try:
9310                    destID = newGraph.destination(fromID, takeTransition)
9311                except MissingDecisionError:
9312                    raise ImpossibleActionError(
9313                        f"Invalid exploration state: focal point"
9314                        f" {repr(moveWhich)} in domain"
9315                        f" {repr(moveInDomain)} specifies decision"
9316                        f" {fromID} as the current position, but"
9317                        f" that decision does not exist!"
9318                    )
9319                except MissingTransitionError:
9320                    valid = newGraph.destinationsFrom(fromID)
9321                    listing = newGraph.destinationsListing(valid)
9322                    raise ImpossibleActionError(
9323                        f"Invalid ExplorationAction: move of focal"
9324                        f" point {repr(moveWhich)} in domain"
9325                        f" {repr(moveInDomain)} along transition"
9326                        f" {repr(takeTransition)} is invalid because"
9327                        f" that focal point is at decision"
9328                        f" {newGraph.identityOf(fromID)} and that"
9329                        f" decision does not have an outgoing"
9330                        f" transition with that name.\nValid"
9331                        f" transitions from that decision are:"
9332                        f"\n{listing}"
9333                    )
9334
9335            else:
9336                raise InvalidActionError(
9337                    f"Invalid ExplorationAction: unrecognized"
9338                    f" 'explore', 'take' or 'warp' format:"
9339                    f"\n{action}"
9340                )
9341
9342            # If we're exploring, update information for the destination
9343            if action[0] == 'explore':
9344                zone = cast(
9345                    Union[base.Zone, None, type[base.DefaultZone]],
9346                    action[-1]
9347                )
9348                recipName = cast(Optional[base.Transition], action[-2])
9349                destOrName = cast(
9350                    Union[base.DecisionName, base.DecisionID, None],
9351                    action[-3]
9352                )
9353                if isinstance(destOrName, base.DecisionID):
9354                    destID = destOrName
9355
9356                if fromID is None or takeTransition is None:
9357                    raise ImpossibleActionError(
9358                        f"Invalid ExplorationAction: exploration"
9359                        f" has unclear origin decision or transition."
9360                        f" Got:\n{action}"
9361                    )
9362
9363                currentDest = newGraph.destination(fromID, takeTransition)
9364                if not newGraph.isConfirmed(currentDest):
9365                    newGraph.replaceUnconfirmed(
9366                        fromID,
9367                        takeTransition,
9368                        destOrName,
9369                        recipName,
9370                        placeInZone=zone,
9371                        forceNew=not isinstance(destOrName, base.DecisionID)
9372                    )
9373                else:
9374                    # Otherwise, since the destination already existed
9375                    # and was hooked up at the right decision, no graph
9376                    # edits need to be made, unless we need to rename
9377                    # the reciprocal.
9378                    # TODO: Do we care about zones here?
9379                    if recipName is not None:
9380                        oldReciprocal = newGraph.getReciprocal(
9381                            fromID,
9382                            takeTransition
9383                        )
9384                        if (
9385                            oldReciprocal is not None
9386                        and oldReciprocal != recipName
9387                        ):
9388                            newGraph.addTransition(
9389                                destID,
9390                                recipName,
9391                                fromID,
9392                                None
9393                            )
9394                            newGraph.setReciprocal(
9395                                destID,
9396                                recipName,
9397                                takeTransition,
9398                                setBoth=True
9399                            )
9400                            newGraph.mergeTransitions(
9401                                destID,
9402                                oldReciprocal,
9403                                recipName
9404                            )
9405
9406            # If we are moving along a transition, check requirements
9407            # and apply transition effects *before* updating our
9408            # position, and check that they don't cancel the normal
9409            # position update
9410            finalDest = None
9411            if takeTransition is not None:
9412                assert fromID is not None  # both or neither
9413                if not self.isTraversable(fromID, takeTransition):
9414                    req = now.graph.getTransitionRequirement(
9415                        fromID,
9416                        takeTransition
9417                    )
9418                    # TODO: Alter warning message if transition is
9419                    # deactivated vs. requirement not satisfied
9420                    warnings.warn(
9421                        (
9422                            f"The requirements for transition"
9423                            f" {takeTransition!r} from decision"
9424                            f" {now.graph.identityOf(fromID)} are"
9425                            f" not met at step {len(self) - 1} (or that"
9426                            f" transition has been deactivated):\n{req}"
9427                        ),
9428                        TransitionBlockedWarning
9429                    )
9430
9431                # Apply transition consequences to our new state and
9432                # figure out if we need to skip our normal update or not
9433                finalDest = self.applyTransitionConsequence(
9434                    fromID,
9435                    (takeTransition, outcomes),
9436                    moveWhich,
9437                    challengePolicy
9438                )
9439
9440            # Check moveInDomain
9441            destDomain = newGraph.domainFor(destID)
9442            if moveInDomain is not None and moveInDomain != destDomain:
9443                raise ImpossibleActionError(
9444                    f"Invalid ExplorationAction: move specified"
9445                    f" domain {repr(moveInDomain)} as the domain of"
9446                    f" the focal point to move, but the destination"
9447                    f" of the move is {now.graph.identityOf(destID)}"
9448                    f" which is in domain {repr(destDomain)}, so focal"
9449                    f" point {repr(moveWhich)} cannot be moved there."
9450                )
9451
9452            # Now that we know where we're going, update position
9453            # information (assuming it wasn't already set):
9454            if finalDest is None:
9455                finalDest = destID
9456                base.updatePosition(
9457                    updated,
9458                    destID,
9459                    cSpec,
9460                    moveWhich
9461                )
9462
9463            destIDs.add(finalDest)
9464
9465        elif action[0] == "focus":
9466            # Figure out target context
9467            action = cast(
9468                Tuple[
9469                    Literal['focus'],
9470                    base.ContextSpecifier,
9471                    Set[base.Domain],
9472                    Set[base.Domain]
9473                ],
9474                action
9475            )
9476            contextSpecifier: base.ContextSpecifier = action[1]
9477            if contextSpecifier == 'common':
9478                targetContext = newState['common']
9479            else:
9480                targetContext = newState['contexts'][
9481                    newState['activeContext']
9482                ]
9483
9484            # Just need to swap out active domains
9485            goingOut, comingIn = cast(
9486                Tuple[Set[base.Domain], Set[base.Domain]],
9487                action[2:]
9488            )
9489            if (
9490                not isinstance(goingOut, set)
9491             or not isinstance(comingIn, set)
9492             or not all(isinstance(d, base.Domain) for d in goingOut)
9493             or not all(isinstance(d, base.Domain) for d in comingIn)
9494            ):
9495                raise InvalidActionError(
9496                    f"Invalid ExplorationAction tuple (must have 4"
9497                    f" parts if the first part is 'focus' and"
9498                    f" the third and fourth parts must be sets of"
9499                    f" domains):"
9500                    f"\n{repr(action)}"
9501                )
9502            activeSet = targetContext['activeDomains']
9503            for dom in goingOut:
9504                try:
9505                    activeSet.remove(dom)
9506                except KeyError:
9507                    warnings.warn(
9508                        (
9509                            f"Domain {repr(dom)} was deactivated at"
9510                            f" step {len(self)} but it was already"
9511                            f" inactive at that point."
9512                        ),
9513                        InactiveDomainWarning
9514                    )
9515            # TODO: Also warn for doubly-activated domains?
9516            activeSet |= comingIn
9517
9518            # destIDs remains empty in this case
9519
9520        elif action[0] == 'swap':  # update which `FocalContext` is active
9521            newContext = cast(base.FocalContextName, action[1])
9522            if newContext not in newState['contexts']:
9523                raise MissingFocalContextError(
9524                    f"'swap' action with target {repr(newContext)} is"
9525                    f" invalid because no context with that name"
9526                    f" exists."
9527                )
9528            newState['activeContext'] = newContext
9529
9530            # destIDs remains empty in this case
9531
9532        elif action[0] == 'focalize':  # create new `FocalContext`
9533            newContext = cast(base.FocalContextName, action[1])
9534            if newContext in newState['contexts']:
9535                raise FocalContextCollisionError(
9536                    f"'focalize' action with target {repr(newContext)}"
9537                    f" is invalid because a context with that name"
9538                    f" already exists."
9539                )
9540            newState['contexts'][newContext] = base.emptyFocalContext()
9541            newState['activeContext'] = newContext
9542
9543            # destIDs remains empty in this case
9544
9545        # revertTo is handled above
9546        else:
9547            raise InvalidActionError(
9548                f"Invalid ExplorationAction tuple (first item must be"
9549                f" an ExplorationActionType, and tuple must be length-1"
9550                f" if the action type is 'noAction'):"
9551                f"\n{repr(action)}"
9552            )
9553
9554        # Apply any active triggers
9555        followTo = self.applyActiveTriggers()
9556        if followTo is not None:
9557            destIDs.add(followTo)
9558            # TODO: Re-work to work with multiple position updates in
9559            # different focal contexts, domains, and/or for different
9560            # focal points in plural-focalized domains.
9561
9562        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]:
9564    def applyActiveTriggers(self) -> Optional[base.DecisionID]:
9565        """
9566        Finds all actions with the 'trigger' tag attached to currently
9567        active decisions, and applies their effects if their requirements
9568        are met (ordered by decision-ID with ties broken alphabetically
9569        by action name).
9570
9571        'bounce', 'goto' and 'follow' effects may apply. However, any
9572        new triggers that would be activated because of decisions
9573        reached by such effects will not apply. Note that 'bounce'
9574        effects update position to the decision where the action was
9575        attached, which is usually a no-op. This function returns the
9576        decision ID of the decision reached by the last decision-moving
9577        effect applied, or `None` if no such effects triggered.
9578
9579        TODO: What about situations where positions are updated in
9580        multiple domains or multiple foal points in a plural domain are
9581        independently updated?
9582
9583        TODO: Tests for this!
9584        """
9585        active = self.getActiveDecisions()
9586        now = self.getSituation()
9587        graph = now.graph
9588        finalFollow = None
9589        for decision in sorted(active):
9590            for action in graph.decisionActions(decision):
9591                if (
9592                    'trigger' in graph.transitionTags(decision, action)
9593                and self.isTraversable(decision, action)
9594                ):
9595                    followTo = self.applyTransitionConsequence(
9596                        decision,
9597                        action
9598                    )
9599                    if followTo is not None:
9600                        # TODO: How will triggers interact with
9601                        # plural-focalized domains? Probably need to fix
9602                        # this to detect moveWhich based on which focal
9603                        # points are at the decision where the transition
9604                        # is, and then apply this to each of them?
9605                        base.updatePosition(now, followTo)
9606                        finalFollow = followTo
9607
9608        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: Union[str, type[exploration.base.DefaultZone], NoneType] = <class 'exploration.base.DefaultZone'>, 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:
9610    def explore(
9611        self,
9612        transition: base.AnyTransition,
9613        destination: Union[base.DecisionName, base.DecisionID, None],
9614        reciprocal: Optional[base.Transition] = None,
9615        zone: Union[
9616            base.Zone,
9617            type[base.DefaultZone],
9618            None
9619        ] = base.DefaultZone,
9620        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
9621        whichFocus: Optional[base.FocalPointSpecifier] = None,
9622        inCommon: Union[bool, Literal["auto"]] = "auto",
9623        decisionType: base.DecisionType = "active",
9624        challengePolicy: base.ChallengePolicy = "specified"
9625    ) -> base.DecisionID:
9626        """
9627        Adds a new situation to the exploration representing the
9628        traversal of the specified transition (possibly with outcomes
9629        specified for challenges among that transitions consequences).
9630        Uses `deduceTransitionDetailsAtStep` to figure out from the
9631        transition name which specific transition is taken (and which
9632        focal point is updated if necessary). This uses the
9633        `fromDecision`, `whichFocus`, and `inCommon` optional
9634        parameters, and also determines whether to update the common or
9635        the active `FocalContext`. Sets the exploration status of the
9636        decision explored to 'exploring'. Returns the decision ID for
9637        the destination reached, accounting for goto/bounce/follow
9638        effects that might have triggered.
9639
9640        The `destination` will be used to name the newly-explored
9641        decision, except when it's a `DecisionID`, in which case that
9642        decision must be unvisited, and we'll connect the specified
9643        transition to that decision.
9644
9645        The focalization of the destination domain in the context to be
9646        updated determines how active decisions are changed:
9647
9648        - If the destination domain is focalized as 'single', then in
9649            the subsequent `Situation`, the destination decision will
9650            become the single active decision in that domain.
9651        - If it's focalized as 'plural', then one of the
9652            `FocalPointName`s for that domain will be moved to activate
9653            that decision; which one can be specified using `whichFocus`
9654            or if left unspecified, will be deduced: if the starting
9655            decision is in the same domain, then the
9656            alphabetically-earliest focal point which is at the starting
9657            decision will be moved. If the starting position is in a
9658            different domain, then the alphabetically earliest focal
9659            point among all focal points in the destination domain will
9660            be moved.
9661        - If it's focalized as 'spreading', then the destination
9662            decision will be added to the set of active decisions in
9663            that domain, without removing any.
9664
9665        The transition named must have been pointing to an unvisited
9666        decision (see `hasBeenVisited`), and the name of that decision
9667        will be updated if a `destination` value is given (a
9668        `DecisionCollisionWarning` will be issued if the destination
9669        name is a duplicate of another name in the graph, although this
9670        is not an error). Additionally:
9671
9672        - If a `reciprocal` name is specified, the reciprocal transition
9673            will be renamed using that name, or created with that name if
9674            it didn't already exist. If reciprocal is left as `None` (the
9675            default) then no change will be made to the reciprocal
9676            transition, and it will not be created if it doesn't exist.
9677        - If a `zone` is specified, the newly-explored decision will be
9678            added to that zone (and that zone will be created at level 0
9679            if it didn't already exist). If `zone` is set to `None` then
9680            it will not be added to any new zones. If `zone` is left as
9681            the default (the `DefaultZone` class) then the explored
9682            decision will be added to each zone that the decision it was
9683            explored from is a part of. If a zone needs to be created,
9684            that zone will be added as a sub-zone of each zone which is a
9685            parent of a zone that directly contains the origin decision.
9686        - An `ExplorationStatusError` will be raised if the specified
9687            transition leads to a decision whose `ExplorationStatus` is
9688            'exploring' or higher (i.e., `hasBeenVisited`). (Use
9689            `returnTo` instead to adjust things when a transition to an
9690            unknown destination turns out to lead to an already-known
9691            destination.)
9692        - A `TransitionBlockedWarning` will be issued if the specified
9693            transition is not traversable given the current game state
9694            (but in that last case the step will still be taken).
9695        - By default, the decision type for the new step will be
9696            'active', but a `decisionType` value can be specified to
9697            override that.
9698        - By default, the 'mostLikely' `ChallengePolicy` will be used to
9699            resolve challenges in the consequence of the transition
9700            taken, but an alternate policy can be supplied using the
9701            `challengePolicy` argument.
9702        """
9703        now = self.getSituation()
9704
9705        transitionName, outcomes = base.nameAndOutcomes(transition)
9706
9707        # Deduce transition details from the name + optional specifiers
9708        (
9709            using,
9710            fromID,
9711            destID,
9712            whichFocus
9713        ) = self.deduceTransitionDetailsAtStep(
9714            -1,
9715            transitionName,
9716            fromDecision,
9717            whichFocus,
9718            inCommon
9719        )
9720
9721        # Issue a warning if the destination name is already in use
9722        if destination is not None:
9723            if isinstance(destination, base.DecisionName):
9724                try:
9725                    existingID = now.graph.resolveDecision(destination)
9726                    collision = existingID != destID
9727                except MissingDecisionError:
9728                    collision = False
9729                except AmbiguousDecisionSpecifierError:
9730                    collision = True
9731
9732                if collision and WARN_OF_NAME_COLLISIONS:
9733                    warnings.warn(
9734                        (
9735                            f"The destination name {repr(destination)} is"
9736                            f" already in use when exploring transition"
9737                            f" {repr(transition)} from decision"
9738                            f" {now.graph.identityOf(fromID)} at step"
9739                            f" {len(self) - 1}."
9740                        ),
9741                        DecisionCollisionWarning
9742                    )
9743
9744        # TODO: Different terminology for "exploration state above
9745        # noticed" vs. "DG thinks it's been visited"...
9746        if (
9747            self.hasBeenVisited(destID)
9748        ):
9749            raise ExplorationStatusError(
9750                f"Cannot explore to decision"
9751                f" {now.graph.identityOf(destID)} because it has"
9752                f" already been visited. Use returnTo instead of"
9753                f" explore when discovering a connection back to a"
9754                f" previously-explored decision."
9755            )
9756
9757        if (
9758            isinstance(destination, base.DecisionID)
9759        and self.hasBeenVisited(destination)
9760        ):
9761            raise ExplorationStatusError(
9762                f"Cannot explore to decision"
9763                f" {now.graph.identityOf(destination)} because it has"
9764                f" already been visited. Use returnTo instead of"
9765                f" explore when discovering a connection back to a"
9766                f" previously-explored decision."
9767            )
9768
9769        actionTaken: base.ExplorationAction = (
9770            'explore',
9771            using,
9772            fromID,
9773            (transitionName, outcomes),
9774            destination,
9775            reciprocal,
9776            zone
9777        )
9778        if whichFocus is not None:
9779            # A move-from-specific-focal-point action
9780            actionTaken = (
9781                'explore',
9782                whichFocus,
9783                (transitionName, outcomes),
9784                destination,
9785                reciprocal,
9786                zone
9787            )
9788
9789        # Advance the situation, applying transition effects and
9790        # updating the destination decision.
9791        _, finalDest = self.advanceSituation(
9792            actionTaken,
9793            decisionType,
9794            challengePolicy
9795        )
9796
9797        # TODO: Is this assertion always valid?
9798        assert len(finalDest) == 1
9799        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 DefaultZone class) 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:
9801    def returnTo(
9802        self,
9803        transition: base.AnyTransition,
9804        destination: base.AnyDecisionSpecifier,
9805        reciprocal: Optional[base.Transition] = None,
9806        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
9807        whichFocus: Optional[base.FocalPointSpecifier] = None,
9808        inCommon: Union[bool, Literal["auto"]] = "auto",
9809        decisionType: base.DecisionType = "active",
9810        challengePolicy: base.ChallengePolicy = "specified"
9811    ) -> base.DecisionID:
9812        """
9813        Adds a new graph to the exploration that replaces the given
9814        transition at the current position (which must lead to an unknown
9815        node, or a `MissingDecisionError` will result). The new
9816        transition will connect back to the specified destination, which
9817        must already exist (or a different `ValueError` will be raised).
9818        Returns the decision ID for the destination reached.
9819
9820        Deduces transition details using the optional `fromDecision`,
9821        `whichFocus`, and `inCommon` arguments in addition to the
9822        `transition` value; see `deduceTransitionDetailsAtStep`.
9823
9824        If a `reciprocal` transition is specified, that transition must
9825        either not already exist in the destination decision or lead to
9826        an unknown region; it will be replaced (or added) as an edge
9827        leading back to the current position.
9828
9829        The `decisionType` and `challengePolicy` optional arguments are
9830        used for `advanceSituation`.
9831
9832        A `TransitionBlockedWarning` will be issued if the requirements
9833        for the transition are not met, but the step will still be taken.
9834        Raises a `MissingDecisionError` if there is no current
9835        transition.
9836        """
9837        now = self.getSituation()
9838
9839        transitionName, outcomes = base.nameAndOutcomes(transition)
9840
9841        # Deduce transition details from the name + optional specifiers
9842        (
9843            using,
9844            fromID,
9845            destID,
9846            whichFocus
9847        ) = self.deduceTransitionDetailsAtStep(
9848            -1,
9849            transitionName,
9850            fromDecision,
9851            whichFocus,
9852            inCommon
9853        )
9854
9855        # Replace with connection to existing destination
9856        destID = now.graph.resolveDecision(destination)
9857        if not self.hasBeenVisited(destID):
9858            raise ExplorationStatusError(
9859                f"Cannot return to decision"
9860                f" {now.graph.identityOf(destID)} because it has NOT"
9861                f" already been at least partially explored. Use"
9862                f" explore instead of returnTo when discovering a"
9863                f" connection to a previously-unexplored decision."
9864            )
9865
9866        now.graph.replaceUnconfirmed(
9867            fromID,
9868            transitionName,
9869            destID,
9870            reciprocal
9871        )
9872
9873        # A move-from-decision action
9874        actionTaken: base.ExplorationAction = (
9875            'take',
9876            using,
9877            fromID,
9878            (transitionName, outcomes)
9879        )
9880        if whichFocus is not None:
9881            # A move-from-specific-focal-point action
9882            actionTaken = ('take', whichFocus, (transitionName, outcomes))
9883
9884        # Next, advance the situation, applying transition effects
9885        _, finalDest = self.advanceSituation(
9886            actionTaken,
9887            decisionType,
9888            challengePolicy
9889        )
9890
9891        assert len(finalDest) == 1
9892        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:
 9894    def takeAction(
 9895        self,
 9896        action: base.AnyTransition,
 9897        requires: Optional[base.Requirement] = None,
 9898        consequence: Optional[base.Consequence] = None,
 9899        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
 9900        whichFocus: Optional[base.FocalPointSpecifier] = None,
 9901        inCommon: Union[bool, Literal["auto"]] = "auto",
 9902        decisionType: base.DecisionType = "active",
 9903        challengePolicy: base.ChallengePolicy = "specified"
 9904    ) -> base.DecisionID:
 9905        """
 9906        Adds a new graph to the exploration based on taking the given
 9907        action, which must be a self-transition in the graph. If the
 9908        action does not already exist in the graph, it will be created.
 9909        Either way if requirements and/or a consequence are supplied,
 9910        the requirements and consequence of the action will be updated
 9911        to match them, and those are the requirements/consequence that
 9912        will count.
 9913
 9914        Returns the decision ID for the decision reached, which normally
 9915        is the same action you were just at, but which might be altered
 9916        by goto, bounce, and/or follow effects.
 9917
 9918        Issues a `TransitionBlockedWarning` if the current game state
 9919        doesn't satisfy the requirements for the action.
 9920
 9921        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
 9922        used for `deduceTransitionDetailsAtStep`, while `decisionType`
 9923        and `challengePolicy` are used for `advanceSituation`.
 9924
 9925        When an action is being created, `fromDecision` (or
 9926        `whichFocus`) must be specified, since the source decision won't
 9927        be deducible from the transition name. Note that if a transition
 9928        with the given name exists from *any* active decision, it will
 9929        be used instead of creating a new action (possibly resulting in
 9930        an error if it's not a self-loop transition). Also, you may get
 9931        an `AmbiguousTransitionError` if several transitions with that
 9932        name exist; in that case use `fromDecision` and/or `whichFocus`
 9933        to disambiguate.
 9934        """
 9935        now = self.getSituation()
 9936        graph = now.graph
 9937
 9938        actionName, outcomes = base.nameAndOutcomes(action)
 9939
 9940        try:
 9941            (
 9942                using,
 9943                fromID,
 9944                destID,
 9945                whichFocus
 9946            ) = self.deduceTransitionDetailsAtStep(
 9947                -1,
 9948                actionName,
 9949                fromDecision,
 9950                whichFocus,
 9951                inCommon
 9952            )
 9953
 9954            if destID != fromID:
 9955                raise ValueError(
 9956                    f"Cannot take action {repr(action)} because it's a"
 9957                    f" transition to another decision, not an action"
 9958                    f" (use explore, returnTo, and/or retrace instead)."
 9959                )
 9960
 9961        except MissingTransitionError:
 9962            using = 'active'
 9963            if inCommon is True:
 9964                using = 'common'
 9965
 9966            if fromDecision is not None:
 9967                fromID = graph.resolveDecision(fromDecision)
 9968            elif whichFocus is not None:
 9969                maybeFromID = base.resolvePosition(now, whichFocus)
 9970                if maybeFromID is None:
 9971                    raise MissingDecisionError(
 9972                        f"Focal point {repr(whichFocus)} was specified"
 9973                        f" in takeAction but that focal point doesn't"
 9974                        f" have a position."
 9975                    )
 9976                else:
 9977                    fromID = maybeFromID
 9978            else:
 9979                raise AmbiguousTransitionError(
 9980                    f"Taking action {repr(action)} is ambiguous because"
 9981                    f" the source decision has not been specified via"
 9982                    f" either fromDecision or whichFocus, and we"
 9983                    f" couldn't find an existing action with that name."
 9984                )
 9985
 9986            # Since the action doesn't exist, add it:
 9987            graph.addAction(fromID, actionName, requires, consequence)
 9988
 9989        # Update the transition requirement/consequence if requested
 9990        # (before the action is taken)
 9991        if requires is not None:
 9992            graph.setTransitionRequirement(fromID, actionName, requires)
 9993        if consequence is not None:
 9994            graph.setConsequence(fromID, actionName, consequence)
 9995
 9996        # A move-from-decision action
 9997        actionTaken: base.ExplorationAction = (
 9998            'take',
 9999            using,
10000            fromID,
10001            (actionName, outcomes)
10002        )
10003        if whichFocus is not None:
10004            # A move-from-specific-focal-point action
10005            actionTaken = ('take', whichFocus, (actionName, outcomes))
10006
10007        _, finalDest = self.advanceSituation(
10008            actionTaken,
10009            decisionType,
10010            challengePolicy
10011        )
10012
10013        assert len(finalDest) in (0, 1)
10014        if len(finalDest) == 1:
10015            return next(x for x in finalDest)
10016        else:
10017            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:
10019    def retrace(
10020        self,
10021        transition: base.AnyTransition,
10022        fromDecision: Optional[base.AnyDecisionSpecifier] = None,
10023        whichFocus: Optional[base.FocalPointSpecifier] = None,
10024        inCommon: Union[bool, Literal["auto"]] = "auto",
10025        decisionType: base.DecisionType = "active",
10026        challengePolicy: base.ChallengePolicy = "specified"
10027    ) -> base.DecisionID:
10028        """
10029        Adds a new graph to the exploration based on taking the given
10030        transition, which must already exist and which must not lead to
10031        an unknown region. Returns the ID of the destination decision,
10032        accounting for goto, bounce, and/or follow effects.
10033
10034        Issues a `TransitionBlockedWarning` if the current game state
10035        doesn't satisfy the requirements for the transition.
10036
10037        The `fromDecision`, `whichFocus`, and `inCommon` arguments are
10038        used for `deduceTransitionDetailsAtStep`, while `decisionType`
10039        and `challengePolicy` are used for `advanceSituation`.
10040        """
10041        now = self.getSituation()
10042
10043        transitionName, outcomes = base.nameAndOutcomes(transition)
10044
10045        (
10046            using,
10047            fromID,
10048            destID,
10049            whichFocus
10050        ) = self.deduceTransitionDetailsAtStep(
10051            -1,
10052            transitionName,
10053            fromDecision,
10054            whichFocus,
10055            inCommon
10056        )
10057
10058        visited = self.hasBeenVisited(destID)
10059        confirmed = now.graph.isConfirmed(destID)
10060        if not confirmed:
10061            raise ExplorationStatusError(
10062                f"Cannot retrace transition {transition!r} from"
10063                f" decision {now.graph.identityOf(fromID)} because it"
10064                f" leads to an unconfirmed decision.\nUse"
10065                f" `DiscreteExploration.explore` and provide"
10066                f" destination decision details instead."
10067            )
10068        if not visited:
10069            raise ExplorationStatusError(
10070                f"Cannot retrace transition {transition!r} from"
10071                f" decision {now.graph.identityOf(fromID)} because it"
10072                f" leads to an unvisited decision.\nUse"
10073                f" `DiscreteExploration.explore` and provide"
10074                f" destination decision details instead."
10075            )
10076
10077        # A move-from-decision action
10078        actionTaken: base.ExplorationAction = (
10079            'take',
10080            using,
10081            fromID,
10082            (transitionName, outcomes)
10083        )
10084        if whichFocus is not None:
10085            # A move-from-specific-focal-point action
10086            actionTaken = ('take', whichFocus, (transitionName, outcomes))
10087
10088        _, finalDest = self.advanceSituation(
10089            actionTaken,
10090            decisionType,
10091        challengePolicy
10092    )
10093
10094        assert len(finalDest) == 1
10095        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: Union[str, type[exploration.base.DefaultZone], NoneType] = <class 'exploration.base.DefaultZone'>, 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:
10097    def warp(
10098        self,
10099        destination: base.AnyDecisionSpecifier,
10100        consequence: Optional[base.Consequence] = None,
10101        domain: Optional[base.Domain] = None,
10102        zone: Union[
10103            base.Zone,
10104            type[base.DefaultZone],
10105            None
10106        ] = base.DefaultZone,
10107        whichFocus: Optional[base.FocalPointSpecifier] = None,
10108        inCommon: Union[bool] = False,
10109        decisionType: base.DecisionType = "active",
10110        challengePolicy: base.ChallengePolicy = "specified"
10111    ) -> base.DecisionID:
10112        """
10113        Adds a new graph to the exploration that's a copy of the current
10114        graph, with the position updated to be at the destination without
10115        actually creating a transition from the old position to the new
10116        one. Returns the ID of the decision warped to (accounting for
10117        any goto or follow effects triggered).
10118
10119        Any provided consequences are applied, but are not associated
10120        with any transition (so any delays and charges are ignored, and
10121        'bounce' effects don't actually cancel the warp). 'goto' or
10122        'follow' effects might change the warp destination; 'follow'
10123        effects take the original destination as their starting point.
10124        Any mechanisms mentioned in extra consequences will be found
10125        based on the destination. Outcomes in supplied challenges should
10126        be pre-specified, or else they will be resolved with the
10127        `challengePolicy`.
10128
10129        `whichFocus` may be specified when the destination domain's
10130        focalization is 'plural' but for 'singular' or 'spreading'
10131        destination domains it is not allowed. `inCommon` determines
10132        whether the common or the active focal context is updated
10133        (default is to update the active context). The `decisionType`
10134        and `challengePolicy` are used for `advanceSituation`.
10135
10136        - If the destination did not already exist, it will be created.
10137            Initially, it will be disconnected from all other decisions.
10138            In this case, the `domain` value can be used to put it in a
10139            non-default domain.
10140        - The position is set to the specified destination, and if a
10141            `consequence` is specified it is applied. Note that
10142            'deactivate' effects are NOT allowed, and 'edit' effects
10143            must establish their own transition target because there is
10144            no transition that the effects are being applied to.
10145        - If the destination had been unexplored, its exploration status
10146            will be set to 'exploring'.
10147        - If a `zone` is specified, the destination will be added to that
10148            zone (even if the destination already existed) and that zone
10149            will be created (as a level-0 zone) if need be. If `zone` is
10150            set to `None`, then no zone will be applied. If `zone` is
10151            left as the default (`DefaultZone`) and the focalization of
10152            the destination domain is 'singular' or 'plural' and the
10153            destination is newly created and there is an origin and the
10154            origin is in the same domain as the destination, then the
10155            destination will be added to all zones that the origin was a
10156            part of if the destination is newly created, but otherwise
10157            the destination will not be added to any zones. If the
10158            specified zone has to be created and there's an origin
10159            decision, it will be added as a sub-zone to all parents of
10160            zones directly containing the origin, as long as the origin
10161            is in the same domain as the destination.
10162        """
10163        now = self.getSituation()
10164        graph = now.graph
10165
10166        fromID: Optional[base.DecisionID]
10167
10168        new = False
10169        try:
10170            destID = graph.resolveDecision(destination)
10171        except MissingDecisionError:
10172            if isinstance(destination, tuple):
10173                # just the name; ignore zone/domain
10174                destination = destination[-1]
10175
10176            if not isinstance(destination, base.DecisionName):
10177                raise TypeError(
10178                    f"Warp destination {repr(destination)} does not"
10179                    f" exist, and cannot be created as it is not a"
10180                    f" decision name."
10181                )
10182            destID = graph.addDecision(destination, domain)
10183            graph.tagDecision(destID, 'unconfirmed')
10184            self.setExplorationStatus(destID, 'unknown')
10185            new = True
10186
10187        using: base.ContextSpecifier
10188        if inCommon:
10189            targetContext = self.getCommonContext()
10190            using = "common"
10191        else:
10192            targetContext = self.getActiveContext()
10193            using = "active"
10194
10195        destDomain = graph.domainFor(destID)
10196        targetFocalization = base.getDomainFocalization(
10197            targetContext,
10198            destDomain
10199        )
10200        if targetFocalization == 'singular':
10201            targetActive = targetContext['activeDecisions']
10202            if destDomain in targetActive:
10203                fromID = cast(
10204                    base.DecisionID,
10205                    targetContext['activeDecisions'][destDomain]
10206                )
10207            else:
10208                fromID = None
10209        elif targetFocalization == 'plural':
10210            if whichFocus is None:
10211                raise AmbiguousTransitionError(
10212                    f"Warping to {repr(destination)} is ambiguous"
10213                    f" becuase domain {repr(destDomain)} has plural"
10214                    f" focalization, and no whichFocus value was"
10215                    f" specified."
10216                )
10217
10218            fromID = base.resolvePosition(
10219                self.getSituation(),
10220                whichFocus
10221            )
10222        else:
10223            fromID = None
10224
10225        # Handle zones
10226        if zone is base.DefaultZone:
10227            if (
10228                new
10229            and fromID is not None
10230            and graph.domainFor(fromID) == destDomain
10231            ):
10232                for prevZone in graph.zoneParents(fromID):
10233                    graph.addDecisionToZone(destination, prevZone)
10234            # Otherwise don't update zones
10235        elif zone is not None:
10236            # Newness is ignored when a zone is specified
10237            zone = cast(base.Zone, zone)
10238            # Create the zone at level 0 if it didn't already exist
10239            if graph.getZoneInfo(zone) is None:
10240                graph.createZone(zone, 0)
10241                # Add the newly created zone to each 2nd-level parent of
10242                # the previous decision if there is one and it's in the
10243                # same domain
10244                if (
10245                    fromID is not None
10246                and graph.domainFor(fromID) == destDomain
10247                ):
10248                    for prevZone in graph.zoneParents(fromID):
10249                        for prevUpper in graph.zoneParents(prevZone):
10250                            graph.addZoneToZone(zone, prevUpper)
10251            # Finally add the destination to the (maybe new) zone
10252            graph.addDecisionToZone(destID, zone)
10253        # else don't touch zones
10254
10255        # Encode the action taken
10256        actionTaken: base.ExplorationAction
10257        if whichFocus is None:
10258            actionTaken = (
10259                'warp',
10260                using,
10261                destID
10262            )
10263        else:
10264            actionTaken = (
10265                'warp',
10266                whichFocus,
10267                destID
10268            )
10269
10270        # Advance the situation
10271        _, finalDests = self.advanceSituation(
10272            actionTaken,
10273            decisionType,
10274            challengePolicy
10275        )
10276        now = self.getSituation()  # updating just in case
10277
10278        assert len(finalDests) == 1
10279        finalDest = next(x for x in finalDests)
10280
10281        # Apply additional consequences:
10282        if consequence is not None:
10283            altDest = self.applyExtraneousConsequence(
10284                consequence,
10285                where=(destID, None),
10286                # TODO: Mechanism search from both ends?
10287                moveWhich=(
10288                    whichFocus[-1]
10289                    if whichFocus is not None
10290                    else None
10291                )
10292            )
10293            if altDest is not None:
10294                finalDest = altDest
10295            now = self.getSituation()  # updating just in case
10296
10297        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 (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]:
10299    def wait(
10300        self,
10301        consequence: Optional[base.Consequence] = None,
10302        decisionType: base.DecisionType = "active",
10303        challengePolicy: base.ChallengePolicy = "specified"
10304    ) -> Optional[base.DecisionID]:
10305        """
10306        Adds a wait step. If a consequence is specified, it is applied,
10307        although it will not have any position/transition information
10308        available during resolution/application.
10309
10310        A decision type other than "active" and/or a challenge policy
10311        other than "specified" can be included (see `advanceSituation`).
10312
10313        The "pending" decision type may not be used, a `ValueError` will
10314        result. This allows None as the action for waiting while
10315        preserving the pending/None type/action combination for
10316        unresolved situations.
10317
10318        If a goto or follow effect in the applied consequence implies a
10319        position update, this will return the new destination ID;
10320        otherwise it will return `None`. Triggering a 'bounce' effect
10321        will be an error, because there is no position information for
10322        the effect.
10323        """
10324        if decisionType == "pending":
10325            raise ValueError(
10326                "The 'pending' decision type may not be used for"
10327                " wait actions."
10328            )
10329        self.advanceSituation(('noAction',), decisionType, challengePolicy)
10330        now = self.getSituation()
10331        if consequence is not None:
10332            if challengePolicy != "specified":
10333                base.resetChallengeOutcomes(consequence)
10334            observed = base.observeChallengeOutcomes(
10335                base.RequirementContext(
10336                    state=now.state,
10337                    graph=now.graph,
10338                    searchFrom=set()
10339                ),
10340                consequence,
10341                location=None,  # No position info
10342                policy=challengePolicy,
10343                knownOutcomes=None  # bake outcomes into the consequence
10344            )
10345            # No location information since we might have multiple
10346            # active decisions and there's no indication of which one
10347            # we're "waiting at."
10348            finalDest = self.applyExtraneousConsequence(observed)
10349            now = self.getSituation()  # updating just in case
10350
10351            return finalDest
10352        else:
10353            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:
10355    def revert(
10356        self,
10357        slot: base.SaveSlot = base.DEFAULT_SAVE_SLOT,
10358        aspects: Optional[Set[str]] = None,
10359        decisionType: base.DecisionType = "active"
10360    ) -> None:
10361        """
10362        Reverts the game state to a previously-saved game state (saved
10363        via a 'save' effect). The save slot name and set of aspects to
10364        revert are required. By default, all aspects except the graph
10365        are reverted.
10366        """
10367        if aspects is None:
10368            aspects = set()
10369
10370        action: base.ExplorationAction = ("revertTo", slot, aspects)
10371
10372        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]:
10374    def observeAll(
10375        self,
10376        where: base.AnyDecisionSpecifier,
10377        *transitions: Union[
10378            base.Transition,
10379            Tuple[base.Transition, base.AnyDecisionSpecifier],
10380            Tuple[
10381                base.Transition,
10382                base.AnyDecisionSpecifier,
10383                base.Transition
10384            ]
10385        ]
10386    ) -> List[base.DecisionID]:
10387        """
10388        Observes one or more new transitions, applying changes to the
10389        current graph. The transitions can be specified in one of three
10390        ways:
10391
10392        1. A transition name. The transition will be created and will
10393            point to a new unexplored node.
10394        2. A pair containing a transition name and a destination
10395            specifier. If the destination does not exist it will be
10396            created as an unexplored node, although in that case the
10397            decision specifier may not be an ID.
10398        3. A triple containing a transition name, a destination
10399            specifier, and a reciprocal name. Works the same as the pair
10400            case but also specifies the name for the reciprocal
10401            transition.
10402
10403        The new transitions are outgoing from specified decision.
10404
10405        Yields the ID of each decision connected to, whether those are
10406        new or existing decisions.
10407        """
10408        now = self.getSituation()
10409        fromID = now.graph.resolveDecision(where)
10410        result = []
10411        for entry in transitions:
10412            if isinstance(entry, base.Transition):
10413                result.append(self.observe(fromID, entry))
10414            else:
10415                result.append(self.observe(fromID, *entry))
10416        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:
10418    def observe(
10419        self,
10420        where: base.AnyDecisionSpecifier,
10421        transition: base.Transition,
10422        destination: Optional[base.AnyDecisionSpecifier] = None,
10423        reciprocal: Optional[base.Transition] = None
10424    ) -> base.DecisionID:
10425        """
10426        Observes a single new outgoing transition from the specified
10427        decision. If specified the transition connects to a specific
10428        destination and/or has a specific reciprocal. The specified
10429        destination will be created if it doesn't exist, or where no
10430        destination is specified, a new unexplored decision will be
10431        added. The ID of the decision connected to is returned.
10432
10433        Sets the exploration status of the observed destination to
10434        "noticed" if a destination is specified and needs to be created
10435        (but not when no destination is specified).
10436
10437        For example:
10438
10439        >>> e = DiscreteExploration()
10440        >>> e.start('start')
10441        0
10442        >>> e.observe('start', 'up')
10443        1
10444        >>> g = e.getSituation().graph
10445        >>> g.destinationsFrom('start')
10446        {'up': 1}
10447        >>> e.getExplorationStatus(1)  # not given a name: assumed unknown
10448        'unknown'
10449        >>> e.observe('start', 'left', 'A')
10450        2
10451        >>> g.destinationsFrom('start')
10452        {'up': 1, 'left': 2}
10453        >>> g.nameFor(2)
10454        'A'
10455        >>> e.getExplorationStatus(2)  # given a name: noticed
10456        'noticed'
10457        >>> e.observe('start', 'up2', 1)
10458        1
10459        >>> g.destinationsFrom('start')
10460        {'up': 1, 'left': 2, 'up2': 1}
10461        >>> e.getExplorationStatus(1)  # existing decision: status unchanged
10462        'unknown'
10463        >>> e.observe('start', 'right', 'B', 'left')
10464        3
10465        >>> g.destinationsFrom('start')
10466        {'up': 1, 'left': 2, 'up2': 1, 'right': 3}
10467        >>> g.nameFor(3)
10468        'B'
10469        >>> e.getExplorationStatus(3)  # new + name -> noticed
10470        'noticed'
10471        >>> e.observe('start', 'right')  # repeat transition name
10472        Traceback (most recent call last):
10473        ...
10474        exploration.core.TransitionCollisionError...
10475        >>> e.observe('start', 'right2', 'B', 'left')  # repeat reciprocal
10476        Traceback (most recent call last):
10477        ...
10478        exploration.core.TransitionCollisionError...
10479        >>> g = e.getSituation().graph
10480        >>> g.createZone('Z', 0)
10481        ZoneInfo(level=0, parents=set(), contents=set(), tags={},\
10482 annotations=[])
10483        >>> g.addDecisionToZone('start', 'Z')
10484        >>> e.observe('start', 'down', 'C', 'up')
10485        4
10486        >>> g.destinationsFrom('start')
10487        {'up': 1, 'left': 2, 'up2': 1, 'right': 3, 'down': 4}
10488        >>> g.identityOf('C')
10489        '4 (C)'
10490        >>> g.zoneParents(4)  # not in any zones, 'cause still unexplored
10491        set()
10492        >>> e.observe(
10493        ...     'C',
10494        ...     'right',
10495        ...     base.DecisionSpecifier('main', 'Z2', 'D'),
10496        ... )  # creates zone
10497        5
10498        >>> g.destinationsFrom('C')
10499        {'up': 0, 'right': 5}
10500        >>> g.destinationsFrom('D')  # default reciprocal name
10501        {'return': 4}
10502        >>> g.identityOf('D')
10503        '5 (Z2::D)'
10504        >>> g.zoneParents(5)
10505        {'Z2'}
10506        """
10507        now = self.getSituation()
10508        fromID = now.graph.resolveDecision(where)
10509
10510        kwargs: Dict[
10511            str,
10512            Union[base.Transition, base.DecisionName, None]
10513        ] = {}
10514        if reciprocal is not None:
10515            kwargs['reciprocal'] = reciprocal
10516
10517        if destination is not None:
10518            try:
10519                destID = now.graph.resolveDecision(destination)
10520                now.graph.addTransition(
10521                    fromID,
10522                    transition,
10523                    destID,
10524                    reciprocal
10525                )
10526                return destID
10527            except MissingDecisionError:
10528                if isinstance(destination, base.DecisionSpecifier):
10529                    kwargs['toDomain'] = destination.domain
10530                    kwargs['placeInZone'] = destination.zone
10531                    kwargs['destinationName'] = destination.name
10532                elif isinstance(destination, base.DecisionName):
10533                    kwargs['destinationName'] = destination
10534                else:
10535                    assert isinstance(destination, base.DecisionID)
10536                    # We got to except by failing to resolve, so it's an
10537                    # invalid ID
10538                    raise
10539
10540        result = now.graph.addUnexploredEdge(
10541            fromID,
10542            transition,
10543            **kwargs  # type: ignore [arg-type]
10544        )
10545        if 'destinationName' in kwargs:
10546            self.setExplorationStatus(result, 'noticed', upgradeOnly=True)
10547        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]:
10549    def observeMechanisms(
10550        self,
10551        where: Optional[base.AnyDecisionSpecifier],
10552        *mechanisms: Union[
10553            base.MechanismName,
10554            Tuple[base.MechanismName, base.MechanismState]
10555        ]
10556    ) -> List[base.MechanismID]:
10557        """
10558        Adds one or more mechanisms to the exploration's current graph,
10559        located at the specified decision. Global mechanisms can be
10560        added by using `None` for the location. Mechanisms are named, or
10561        a (name, state) tuple can be used to set them into a specific
10562        state. Mechanisms not set to a state will be in the
10563        `base.DEFAULT_MECHANISM_STATE`.
10564        """
10565        now = self.getSituation()
10566        result = []
10567        for mSpec in mechanisms:
10568            setState = None
10569            if isinstance(mSpec, base.MechanismName):
10570                result.append(now.graph.addMechanism(mSpec, where))
10571            elif (
10572                isinstance(mSpec, tuple)
10573            and len(mSpec) == 2
10574            and isinstance(mSpec[0], base.MechanismName)
10575            and isinstance(mSpec[1], base.MechanismState)
10576            ):
10577                result.append(now.graph.addMechanism(mSpec[0], where))
10578                setState = mSpec[1]
10579            else:
10580                raise TypeError(
10581                    f"Invalid mechanism: {repr(mSpec)} (must be a"
10582                    f" mechanism name or a (name, state) tuple."
10583                )
10584
10585            if setState:
10586                self.setMechanismStateNow(result[-1], setState)
10587
10588        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:
10590    def reZone(
10591        self,
10592        zone: base.Zone,
10593        where: base.AnyDecisionSpecifier,
10594        replace: Union[base.Zone, int] = 0
10595    ) -> None:
10596        """
10597        Alters the current graph without adding a new exploration step.
10598
10599        Calls `DecisionGraph.replaceZonesInHierarchy` targeting the
10600        specified decision. Note that per the logic of that method, ALL
10601        zones at the specified hierarchy level are replaced, even if a
10602        specific zone to replace is specified here.
10603
10604        TODO: not that?
10605
10606        The level value is either specified via `replace` (default 0) or
10607        deduced from the zone provided as the `replace` value using
10608        `DecisionGraph.zoneHierarchyLevel`.
10609        """
10610        now = self.getSituation()
10611
10612        if isinstance(replace, int):
10613            level = replace
10614        else:
10615            level = now.graph.zoneHierarchyLevel(replace)
10616
10617        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.

10619    def runCommand(
10620        self,
10621        command: commands.Command,
10622        scope: Optional[commands.Scope] = None,
10623        line: int = -1
10624    ) -> commands.CommandResult:
10625        """
10626        Runs a single `Command` applying effects to the exploration, its
10627        current graph, and the provided execution context, and returning
10628        a command result, which contains the modified scope plus
10629        optional skip and label values (see `CommandResult`). This
10630        function also directly modifies the scope you give it. Variable
10631        references in the command are resolved via entries in the
10632        provided scope. If no scope is given, an empty one is created.
10633
10634        A line number may be supplied for use in error messages; if left
10635        out line -1 will be used.
10636
10637        Raises an error if the command is invalid.
10638
10639        For commands that establish a value as the 'current value', that
10640        value will be stored in the '_' variable. When this happens, the
10641        old contents of '_' are stored in '__' first, and the old
10642        contents of '__' are discarded. Note that non-automatic
10643        assignment to '_' does not move the old value to '__'.
10644        """
10645        try:
10646            if scope is None:
10647                scope = {}
10648
10649            skip: Union[int, str, None] = None
10650            label: Optional[str] = None
10651
10652            if command.command == 'val':
10653                command = cast(commands.LiteralValue, command)
10654                result = commands.resolveValue(command.value, scope)
10655                commands.pushCurrentValue(scope, result)
10656
10657            elif command.command == 'empty':
10658                command = cast(commands.EstablishCollection, command)
10659                collection = commands.resolveVarName(command.collection, scope)
10660                commands.pushCurrentValue(
10661                    scope,
10662                    {
10663                        'list': [],
10664                        'tuple': (),
10665                        'set': set(),
10666                        'dict': {},
10667                    }[collection]
10668                )
10669
10670            elif command.command == 'append':
10671                command = cast(commands.AppendValue, command)
10672                target = scope['_']
10673                addIt = commands.resolveValue(command.value, scope)
10674                if isinstance(target, list):
10675                    target.append(addIt)
10676                elif isinstance(target, tuple):
10677                    scope['_'] = target + (addIt,)
10678                elif isinstance(target, set):
10679                    target.add(addIt)
10680                elif isinstance(target, dict):
10681                    raise TypeError(
10682                        "'append' command cannot be used with a"
10683                        " dictionary. Use 'set' instead."
10684                    )
10685                else:
10686                    raise TypeError(
10687                        f"Invalid current value for 'append' command."
10688                        f" The current value must be a list, tuple, or"
10689                        f" set, but it was a '{type(target).__name__}'."
10690                    )
10691
10692            elif command.command == 'set':
10693                command = cast(commands.SetValue, command)
10694                target = scope['_']
10695                where = commands.resolveValue(command.location, scope)
10696                what = commands.resolveValue(command.value, scope)
10697                if isinstance(target, list):
10698                    if not isinstance(where, int):
10699                        raise TypeError(
10700                            f"Cannot set item in list: index {where!r}"
10701                            f" is not an integer."
10702                        )
10703                    target[where] = what
10704                elif isinstance(target, tuple):
10705                    if not isinstance(where, int):
10706                        raise TypeError(
10707                            f"Cannot set item in tuple: index {where!r}"
10708                            f" is not an integer."
10709                        )
10710                    if not (
10711                        0 <= where < len(target)
10712                    or -1 >= where >= -len(target)
10713                    ):
10714                        raise IndexError(
10715                            f"Cannot set item in tuple at index"
10716                            f" {where}: Tuple has length {len(target)}."
10717                        )
10718                    scope['_'] = target[:where] + (what,) + target[where + 1:]
10719                elif isinstance(target, set):
10720                    if what:
10721                        target.add(where)
10722                    else:
10723                        try:
10724                            target.remove(where)
10725                        except KeyError:
10726                            pass
10727                elif isinstance(target, dict):
10728                    target[where] = what
10729
10730            elif command.command == 'pop':
10731                command = cast(commands.PopValue, command)
10732                target = scope['_']
10733                if isinstance(target, list):
10734                    result = target.pop()
10735                    commands.pushCurrentValue(scope, result)
10736                elif isinstance(target, tuple):
10737                    result = target[-1]
10738                    updated = target[:-1]
10739                    scope['__'] = updated
10740                    scope['_'] = result
10741                else:
10742                    raise TypeError(
10743                        f"Cannot 'pop' from a {type(target).__name__}"
10744                        f" (current value must be a list or tuple)."
10745                    )
10746
10747            elif command.command == 'get':
10748                command = cast(commands.GetValue, command)
10749                target = scope['_']
10750                where = commands.resolveValue(command.location, scope)
10751                if isinstance(target, list):
10752                    if not isinstance(where, int):
10753                        raise TypeError(
10754                            f"Cannot get item from list: index"
10755                            f" {where!r} is not an integer."
10756                        )
10757                elif isinstance(target, tuple):
10758                    if not isinstance(where, int):
10759                        raise TypeError(
10760                            f"Cannot get item from tuple: index"
10761                            f" {where!r} is not an integer."
10762                        )
10763                elif isinstance(target, set):
10764                    result = where in target
10765                    commands.pushCurrentValue(scope, result)
10766                elif isinstance(target, dict):
10767                    result = target[where]
10768                    commands.pushCurrentValue(scope, result)
10769                else:
10770                    result = getattr(target, where)
10771                    commands.pushCurrentValue(scope, result)
10772
10773            elif command.command == 'remove':
10774                command = cast(commands.RemoveValue, command)
10775                target = scope['_']
10776                where = commands.resolveValue(command.location, scope)
10777                if isinstance(target, (list, tuple)):
10778                    # this cast is not correct but suppresses warnings
10779                    # given insufficient narrowing by MyPy
10780                    target = cast(Tuple[Any, ...], target)
10781                    if not isinstance(where, int):
10782                        raise TypeError(
10783                            f"Cannot remove item from list or tuple:"
10784                            f" index {where!r} is not an integer."
10785                        )
10786                    scope['_'] = target[:where] + target[where + 1:]
10787                elif isinstance(target, set):
10788                    target.remove(where)
10789                elif isinstance(target, dict):
10790                    del target[where]
10791                else:
10792                    raise TypeError(
10793                        f"Cannot use 'remove' on a/an"
10794                        f" {type(target).__name__}."
10795                    )
10796
10797            elif command.command == 'op':
10798                command = cast(commands.ApplyOperator, command)
10799                left = commands.resolveValue(command.left, scope)
10800                right = commands.resolveValue(command.right, scope)
10801                op = command.op
10802                if op == '+':
10803                    result = left + right
10804                elif op == '-':
10805                    result = left - right
10806                elif op == '*':
10807                    result = left * right
10808                elif op == '/':
10809                    result = left / right
10810                elif op == '//':
10811                    result = left // right
10812                elif op == '**':
10813                    result = left ** right
10814                elif op == '%':
10815                    result = left % right
10816                elif op == '^':
10817                    result = left ^ right
10818                elif op == '|':
10819                    result = left | right
10820                elif op == '&':
10821                    result = left & right
10822                elif op == 'and':
10823                    result = left and right
10824                elif op == 'or':
10825                    result = left or right
10826                elif op == '<':
10827                    result = left < right
10828                elif op == '>':
10829                    result = left > right
10830                elif op == '<=':
10831                    result = left <= right
10832                elif op == '>=':
10833                    result = left >= right
10834                elif op == '==':
10835                    result = left == right
10836                elif op == 'is':
10837                    result = left is right
10838                else:
10839                    raise RuntimeError("Invalid operator '{op}'.")
10840
10841                commands.pushCurrentValue(scope, result)
10842
10843            elif command.command == 'unary':
10844                command = cast(commands.ApplyUnary, command)
10845                value = commands.resolveValue(command.value, scope)
10846                op = command.op
10847                if op == '-':
10848                    result = -value
10849                elif op == '~':
10850                    result = ~value
10851                elif op == 'not':
10852                    result = not value
10853
10854                commands.pushCurrentValue(scope, result)
10855
10856            elif command.command == 'assign':
10857                command = cast(commands.VariableAssignment, command)
10858                varname = commands.resolveVarName(command.varname, scope)
10859                value = commands.resolveValue(command.value, scope)
10860                scope[varname] = value
10861
10862            elif command.command == 'delete':
10863                command = cast(commands.VariableDeletion, command)
10864                varname = commands.resolveVarName(command.varname, scope)
10865                del scope[varname]
10866
10867            elif command.command == 'load':
10868                command = cast(commands.LoadVariable, command)
10869                varname = commands.resolveVarName(command.varname, scope)
10870                commands.pushCurrentValue(scope, scope[varname])
10871
10872            elif command.command == 'call':
10873                command = cast(commands.FunctionCall, command)
10874                function = command.function
10875                if function.startswith('$'):
10876                    function = commands.resolveValue(function, scope)
10877
10878                toCall: Callable
10879                args: Tuple[str, ...]
10880                kwargs: Dict[str, Any]
10881
10882                if command.target == 'builtin':
10883                    toCall = commands.COMMAND_BUILTINS[function]
10884                    args = (scope['_'],)
10885                    kwargs = {}
10886                    if toCall == round:
10887                        if 'ndigits' in scope:
10888                            kwargs['ndigits'] = scope['ndigits']
10889                    elif toCall == range and args[0] is None:
10890                        start = scope.get('start', 0)
10891                        stop = scope['stop']
10892                        step = scope.get('step', 1)
10893                        args = (start, stop, step)
10894
10895                else:
10896                    if command.target == 'stored':
10897                        toCall = function
10898                    elif command.target == 'graph':
10899                        toCall = getattr(self.getSituation().graph, function)
10900                    elif command.target == 'exploration':
10901                        toCall = getattr(self, function)
10902                    else:
10903                        raise TypeError(
10904                            f"Invalid call target '{command.target}'"
10905                            f" (must be one of 'builtin', 'stored',"
10906                            f" 'graph', or 'exploration'."
10907                        )
10908
10909                    # Fill in arguments via kwargs defined in scope
10910                    args = ()
10911                    kwargs = {}
10912                    signature = inspect.signature(toCall)
10913                    # TODO: Maybe try some type-checking here?
10914                    for argName, param in signature.parameters.items():
10915                        if param.kind == inspect.Parameter.VAR_POSITIONAL:
10916                            if argName in scope:
10917                                args = args + tuple(scope[argName])
10918                            # Else leave args as-is
10919                        elif param.kind == inspect.Parameter.KEYWORD_ONLY:
10920                            # These must have a default
10921                            if argName in scope:
10922                                kwargs[argName] = scope[argName]
10923                        elif param.kind == inspect.Parameter.VAR_KEYWORD:
10924                            # treat as a dictionary
10925                            if argName in scope:
10926                                argsToUse = scope[argName]
10927                                if not isinstance(argsToUse, dict):
10928                                    raise TypeError(
10929                                        f"Variable '{argName}' must"
10930                                        f" hold a dictionary when"
10931                                        f" calling function"
10932                                        f" '{toCall.__name__} which"
10933                                        f" uses that argument as a"
10934                                        f" keyword catchall."
10935                                    )
10936                                kwargs.update(scope[argName])
10937                        else:  # a normal parameter
10938                            if argName in scope:
10939                                args = args + (scope[argName],)
10940                            elif param.default == inspect.Parameter.empty:
10941                                raise TypeError(
10942                                    f"No variable named '{argName}' has"
10943                                    f" been defined to supply the"
10944                                    f" required parameter with that"
10945                                    f" name for function"
10946                                    f" '{toCall.__name__}'."
10947                                )
10948
10949                result = toCall(*args, **kwargs)
10950                commands.pushCurrentValue(scope, result)
10951
10952            elif command.command == 'skip':
10953                command = cast(commands.SkipCommands, command)
10954                doIt = commands.resolveValue(command.condition, scope)
10955                if doIt:
10956                    skip = commands.resolveValue(command.amount, scope)
10957                    if not isinstance(skip, (int, str)):
10958                        raise TypeError(
10959                            f"Skip amount must be an integer or a label"
10960                            f" name (got {skip!r})."
10961                        )
10962
10963            elif command.command == 'label':
10964                command = cast(commands.Label, command)
10965                label = commands.resolveValue(command.name, scope)
10966                if not isinstance(label, str):
10967                    raise TypeError(
10968                        f"Label name must be a string (got {label!r})."
10969                    )
10970
10971            else:
10972                raise ValueError(
10973                    f"Invalid command type: {command.command!r}"
10974                )
10975        except ValueError as e:
10976            raise commands.CommandValueError(command, line, e)
10977        except TypeError as e:
10978            raise commands.CommandTypeError(command, line, e)
10979        except IndexError as e:
10980            raise commands.CommandIndexError(command, line, e)
10981        except KeyError as e:
10982            raise commands.CommandKeyError(command, line, e)
10983        except Exception as e:
10984            raise commands.CommandOtherError(command, line, e)
10985
10986        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 '__'.

10988    def runCommandBlock(
10989        self,
10990        block: List[commands.Command],
10991        scope: Optional[commands.Scope] = None
10992    ) -> commands.Scope:
10993        """
10994        Runs a list of commands, using the given scope (or creating a new
10995        empty scope if none was provided). Returns the scope after
10996        running all of the commands, which may also edit the exploration
10997        and/or the current graph of course.
10998
10999        Note that if a skip command would skip past the end of the
11000        block, execution will end. If a skip command would skip before
11001        the beginning of the block, execution will start from the first
11002        command.
11003
11004        Example:
11005
11006        >>> e = DiscreteExploration()
11007        >>> scope = e.runCommandBlock([
11008        ...    commands.command('assign', 'decision', "'START'"),
11009        ...    commands.command('call', 'exploration', 'start'),
11010        ...    commands.command('assign', 'where', '$decision'),
11011        ...    commands.command('assign', 'transition', "'left'"),
11012        ...    commands.command('call', 'exploration', 'observe'),
11013        ...    commands.command('assign', 'transition', "'right'"),
11014        ...    commands.command('call', 'exploration', 'observe'),
11015        ...    commands.command('call', 'graph', 'destinationsFrom'),
11016        ...    commands.command('call', 'builtin', 'print'),
11017        ...    commands.command('assign', 'transition', "'right'"),
11018        ...    commands.command('assign', 'destination', "'EastRoom'"),
11019        ...    commands.command('call', 'exploration', 'explore'),
11020        ... ])
11021        {'left': 1, 'right': 2}
11022        >>> scope['decision']
11023        'START'
11024        >>> scope['where']
11025        'START'
11026        >>> scope['_']  # result of 'explore' call is dest ID
11027        2
11028        >>> scope['transition']
11029        'right'
11030        >>> scope['destination']
11031        'EastRoom'
11032        >>> g = e.getSituation().graph
11033        >>> len(e)
11034        3
11035        >>> len(g)
11036        3
11037        >>> g.namesListing(g)
11038        '  0 (START)\\n  1 (_u.0)\\n  2 (EastRoom)\\n'
11039        """
11040        if scope is None:
11041            scope = {}
11042
11043        labelPositions: Dict[str, List[int]] = {}
11044
11045        # Keep going until we've exhausted the commands list
11046        index = 0
11047        while index < len(block):
11048
11049            # Execute the next command
11050            scope, skip, label = self.runCommand(
11051                block[index],
11052                scope,
11053                index + 1
11054            )
11055
11056            # Increment our index, or apply a skip
11057            if skip is None:
11058                index = index + 1
11059
11060            elif isinstance(skip, int):  # Integer skip value
11061                if skip < 0:
11062                    index += skip
11063                    if index < 0:  # can't skip before the start
11064                        index = 0
11065                else:
11066                    index += skip + 1  # may end loop if we skip too far
11067
11068            else:  # must be a label name
11069                if skip in labelPositions:  # an established label
11070                    # We jump to the last previous index, or if there
11071                    # are none, to the first future index.
11072                    prevIndices = [
11073                        x
11074                        for x in labelPositions[skip]
11075                        if x < index
11076                    ]
11077                    futureIndices = [
11078                        x
11079                        for x in labelPositions[skip]
11080                        if x >= index
11081                    ]
11082                    if len(prevIndices) > 0:
11083                        index = max(prevIndices)
11084                    else:
11085                        index = min(futureIndices)
11086                else:  # must be a forward-reference
11087                    for future in range(index + 1, len(block)):
11088                        inspect = block[future]
11089                        if inspect.command == 'label':
11090                            inspect = cast(commands.Label, inspect)
11091                            if inspect.name == skip:
11092                                index = future
11093                                break
11094                    else:
11095                        raise KeyError(
11096                            f"Skip command indicated a jump to label"
11097                            f" {skip!r} but that label had not already"
11098                            f" been defined and there is no future"
11099                            f" label with that name either (future"
11100                            f" labels based on variables cannot be"
11101                            f" skipped to from above as their names"
11102                            f" are not known yet)."
11103                        )
11104
11105            # If there's a label, record it
11106            if label is not None:
11107                labelPositions.setdefault(label, []).append(index)
11108
11109            # And now the while loop continues, or ends if we're at the
11110            # end of the commands list.
11111
11112        # Return the scope object.
11113        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'