exploration.journal

  • Authors: Peter Mawhorter
  • Consulted:
  • Date: 2022-9-4
  • Purpose: Parsing for journal-format exploration records.

A journal fundamentally consists of a number of lines detailing decisions reached, options observed, and options chosen. Other information like enemies fought, items acquired, or general comments may also be present.

The start of each line is a single letter that determines the entry type, and remaining parts of that line separated by whitespace determine the specifics of that entry. Indentation is allowed and ignored; its suggested use is to indicate which entries apply to previous entries (e.g., tags, annotations, effects, and requirements).

The convertJournal function converts a journal string into a core.DiscreteExploration object, or adds to an existing exploration object if one is specified.

To support slightly different journal formats, a Format dictionary is used to define the exact notation used for various things.

   1"""
   2- Authors: Peter Mawhorter
   3- Consulted:
   4- Date: 2022-9-4
   5- Purpose: Parsing for journal-format exploration records.
   6
   7A journal fundamentally consists of a number of lines detailing
   8decisions reached, options observed, and options chosen. Other
   9information like enemies fought, items acquired, or general comments may
  10also be present.
  11
  12The start of each line is a single letter that determines the entry
  13type, and remaining parts of that line separated by whitespace determine
  14the specifics of that entry. Indentation is allowed and ignored; its
  15suggested use is to indicate which entries apply to previous entries
  16(e.g., tags, annotations, effects, and requirements).
  17
  18The `convertJournal` function converts a journal string into a
  19`core.DiscreteExploration` object, or adds to an existing exploration
  20object if one is specified.
  21
  22To support slightly different journal formats, a `Format` dictionary is
  23used to define the exact notation used for various things.
  24"""
  25
  26# TODO: Base current decision on primary decision when reverting, and
  27# maybe at other points!
  28
  29from __future__ import annotations
  30
  31from typing import (
  32    Optional, List, Tuple, Dict, Union, Collection, get_args, cast,
  33    Sequence, Literal, Set, TypedDict, get_type_hints
  34)
  35
  36import sys
  37import re
  38import warnings
  39import textwrap
  40
  41from . import core, base, parsing
  42
  43
  44#----------------------#
  45# Parse format details #
  46#----------------------#
  47
  48JournalEntryType = Literal[
  49    'preference',
  50    'alias',
  51    'custom',
  52    'DEBUG',
  53
  54    'START',
  55    'explore',
  56    'return',
  57    'action',
  58    'retrace',
  59    'warp',
  60    'wait',
  61    'observe',
  62    'END',
  63
  64    'mechanism',
  65    'requirement',
  66    'effect',
  67    'apply',
  68
  69    'tag',
  70    'annotate',
  71
  72    'context',
  73    'domain',
  74    'focus',
  75    'zone',
  76
  77    'unify',
  78    'obviate',
  79    'extinguish',
  80    'complicate',
  81
  82    'status',
  83
  84    'revert',
  85
  86    'fulfills',
  87
  88    'relative'
  89]
  90"""
  91One of the types of entries that can be present in a journal. These can
  92be written out long form, or abbreviated using a single letter (see
  93`DEFAULT_FORMAT`). Each journal line is either an entry or a continuation
  94of a previous entry. The available types are:
  95
  96- 'P' / 'preference': Followed by a setting name and value, controls
  97    global preferences for journal processing.
  98
  99- '=' / 'alias': Followed by zero or more words and then a block of
 100    commands, this establishes an alias that can be used as a custom
 101    command. Within the command block, curly braces surrounding a word
 102    will be replaced by the argument in the same position that that word
 103    appears following the alias (for example, an alias defined using:
 104
 105        = redDoor name [
 106          o {name}
 107            qb red
 108        ]
 109
 110    could be referenced using:
 111
 112        > redDoor door
 113
 114    and that reference would be equivalent to:
 115
 116        o door
 117          qb red
 118
 119    To help aliases be more flexible, if '_' is referenced between curly
 120    braces (or '_' followed by an integer), it will be substituted with
 121    an underscore followed by a unique number (these numbers will count
 122    up with each such reference used by a specific `JournalObserver`
 123    object). References within each alias substitution which are
 124    suffixed with the same digit (or which are unsuffixed) will get the
 125    same value. So for example, an alias:
 126
 127        = savePoint [
 128          o {_}
 129          x {_} {_1} {_2}
 130              gt toSavePoint
 131          a save
 132            At save
 133          t {_2}
 134        ]
 135
 136    when deployed twice like this:
 137
 138        > savePoint
 139        > savePoint
 140
 141    might translate to:
 142
 143        o _17
 144        x _17 _18 _19
 145            g savePoint
 146        a save
 147          At save
 148        t _19
 149        o _20
 150        x _20 _21 _22
 151            g savePoint
 152        a save
 153          At save
 154        t _22
 155
 156- '>' / 'custom': Re-uses the code from a previously-defined alias. This
 157    command type is followed by an alias name and then one argument for
 158    each parameter of the named alias (see above for examples).
 159
 160- '?' / 'DEBUG': Prints out debugging information when executed. See
 161    `DebugAction` for the possible argument values and `doDebug` for
 162    more information on what they mean.
 163
 164- 'S" / 'START': Names the starting decision (or zone::decision pair).
 165    Must appear first except in journal fragments.
 166
 167- 'x' / 'explore': Names a transition taken and the decision (or
 168    new-zone::decision) reached as a result, possibly with a name for
 169    the reciprocal transition which is created. Use 'zone' afterwards to
 170    swap around zones above level 0.
 171
 172- 'r' / 'return': Names a transition taken and decision returned to,
 173    connecting a transition which previously connected to an unexplored
 174    area back to a known decision instead. May also include a reciprocal
 175    name.
 176
 177- 'a' / 'action': Names an action taken at the current decision and may
 178    include effects and/or requirements. Use to declare a new action
 179    (including transforming what was thought to be a transition into an
 180    action). Use 'retrace' instead (preferably with the 'actionPart'
 181    part) to re-activate an existing action.
 182
 183- 't' / 'retrace': Names a transition taken, where the destination is
 184    already explored. Works for actoins as well when 'actionPart' is
 185    specified, but it will raise an error if the type of transition
 186    (normal vs. action) doesn't match thid specifier.
 187
 188- 'w' / 'wait': indicates a step of exploration where no transition is
 189    taken. You can use 'A' afterwards to apply effects in order to
 190    represent events that happen outside of player control. Use 'action'
 191    instead for player-initiated effects.
 192
 193- 'p' / 'warp': Names a new decision (or zone::decision) to be at, but
 194    without adding a transition there from the previous decision. If no
 195    zone name is provided but the destination is a novel decision, it
 196    will be placed into the same zones as the origin.
 197
 198- 'o' / 'observe': Names a transition observed from the current
 199    decision, or a transition plus destination if the destination is
 200    known, or a transition plus destination plus reciprocal if
 201    reciprocal information is also available. Observations don't create
 202    exploration steps.
 203
 204- 'E' / 'END': Names an ending which is reached from the current
 205    decision via a new automatically-named transition.
 206
 207- 'm' / 'mechanism': names a new mechanism at the current decision and
 208    puts it in a starting state.
 209
 210- 'q' / 'requirement': Specifies a requirement to apply to the
 211    most-recently-defined transition or its reciprocal.
 212
 213- 'e' / 'effect': Specifies a `base.Consequence` that gets added to the
 214    consequence for the currently-relevant transition (or its reciprocal
 215    or both if `reciprocalPart` or `bothPart` is used). The remainder of
 216    the line (and/or the next few lines) should be parsable using
 217    `ParseFormat.parseConsequence`, or if not, using
 218    `ParseFormat.parseEffect` for a single effect.
 219
 220- 'A' / 'apply': Specifies an effect to be immediately applied to the
 221    current state, relative to the most-recently-taken or -defined
 222    transition. If a 'transitionPart' or 'reciprocalPart' target
 223    specifier is included, the effect will also be recorded as an effect
 224    in the current active `core.Consequence` context for the most recent
 225    transition or reciprocal, but otherwise it will just be applied
 226    without being stored in the graph. Note that effects which are
 227    hidden until activated should have their 'hidden' property set to
 228    `True`, regardless of whether they're added to the graph before or
 229    after the transition they are associated with. Also, certain effects
 230    like 'bounce' cannot be applied retroactively.
 231
 232- 'g' / 'tag': Applies one or more tags to the current exploration step,
 233    or to the current decision if 'decisionPart' is specified, or to
 234    either the most-recently-taken transition or its reciprocal if
 235    'transitionPart' or 'reciprocalPart' is specified. May also tag a
 236    zone by using 'zonePart'. Tags may have values associated with them;
 237    without a value provided the default value is the number 1.
 238
 239- 'n' / 'annotate': Like 'tag' but applies an annotation, which is just a
 240    piece of text attached to. Certain annotations will trigger checks of
 241    various exploration state when applied to a step, and will emit
 242    warnings if the checks fail.Step annotations which begin with:
 243        * 'at:' - checks that the current primary decision matches the
 244            decision identified by the rest of the annotation.
 245        * 'active:' - checks that a decision is currently in the active
 246            decision set.
 247        * 'has:' - checks that the player has a specific amount of a
 248            certain token (write 'token*amount' after 'has:', as in "has:
 249            coins*3"). Will fail if the player doesn't have that amount,
 250            including if they have more.
 251        * 'level:' - checks that the level of the named skill matches a
 252            specific level (write 'skill^level', as in "level:
 253            bossFight^3"). This does not allow you to check
 254            `SkillCombination` effective levels; just individual skill
 255            levels.
 256        * 'can:' - checks that a requirement (parsed using
 257            `ParseFormat.parseRequirement`) is satisfied.
 258        * 'state:' - checks that a mechanism is in a certain state (write
 259            'mechanism:state', as in "level: doors:open").
 260        * 'exists:' - checks that a decision exists.
 261
 262- 'c' / 'context': Specifies either 'commonContext' or the name of a
 263    specific focal context to activate. Focal contexts represent things
 264    like multiple characters or teams, and by default capabilities,
 265    tokens, and skills are all tied to a specific focal context. If the
 266    name given is anything other than the 'commonContext' value then
 267    that context will be swapped to active (and created as a blank
 268    context if necessary). TODO: THIS
 269
 270- 'd' / 'domain': Specifies a domain name, swapping to that domain as
 271    the current domain, and setting it as an active domain in the
 272    current `core.FocalContext`. This does not de-activate other
 273    domains, but the journal has a notion of a single 'current' domain
 274    that entries will be applied to. If no focal point has been
 275    specified and we swap into a plural-focalized domain, the
 276    alphabetically-first focal point within that domain will be
 277    selected, but focal points for each domain are remembered when
 278    swapping back. Use the 'notApplicable' value after a domain name to
 279    deactivate that domain. Any other value after a domain name must be
 280    one of the 'focalizeSingular', 'focalizePlural', or
 281    'focalizeSpreading' values to indicate that the domain uses that
 282    type of focalization. These should only be used when a domain is
 283    created, you cannot change the focalization type of a domain after
 284    creation. If no focalization type is given along with a new domain
 285    name, singular focalization will be used for that domain. If no
 286    domain is specified before performing the first action of an
 287    exploration, the `core.DEFAULT_DOMAIN` with singular focalization
 288    will be set up. TODO: THIS
 289
 290- 'f' / 'focus': Specifies a `core.FocalPointName` for the specific
 291    focal point that should be acted on by subsequent journal entries in
 292    a plural-focalized domain. Focal points represent things like
 293    individual units in a game where you have multiple units to control.
 294
 295    May also specify a domain followed by a focal point name to change
 296    the focal point in a domain other than the current domain.
 297
 298- 'z' / 'zone': Specifies a zone name and a level (via extra `zonePart`
 299    characters) that will replace the current zone at the given
 300    hierarchy level for the current decision. This is done using the
 301    `core.DiscreteExploration.reZone` method.
 302
 303- 'u' / 'unify': Specifies a decision with which the current decision
 304    will be unified (or two decisions that will be unified with each
 305    other), merging their transitions. The name of the merged decision
 306    is the name of the second decision specified (or the only decision
 307    specified when merging the current decision). Can instead target a
 308    transition or reciprocal to merge (which must be at the current
 309    decision), although the transition to merge with must either lead to
 310    the same destination or lead to an unknown destination (which will
 311    then be merged with the transition's destination). Any transitions
 312    between the two merged decisions will remain as actions at the new
 313    decision.
 314
 315- 'v' / 'obviate': Specifies a transition at the current decision and a
 316    decision that it links to and updates that information, without
 317    actually crossing the transition. The reciprocal transition must
 318    also be specified, although one will be created if it didn't already
 319    exist. If the reciprocal does already exist, it must lead to an
 320    unknown decision.
 321
 322- 'X' / 'extinguish': Deletes an transition at the current decision. If it
 323    leads to an unknown decision which is not otherwise connected to
 324    anything, this will also delete that decision (even if it already
 325    has tags or annotations or the like). Can also be used (with a
 326    decision target) to delete a decision, which will delete all
 327    transitions touching that decision. Note that usually, 'unify' is
 328    easier to manage than extinguish for manipulating decisions.
 329
 330- 'C' / 'complicate': Takes a transition between two confirmed decisions
 331    and adds a new confirmed decision in the middle of it. The old ends
 332    of the transition both connect to the new decision, and new names are
 333    given to their new reciprocals. Does not change the player's
 334    position.
 335
 336- '.' / 'status': Sets the exploration status of the current decision
 337    (argument should be a `base.ExplorationStatus`). Without an
 338    argument, sets the status to 'explored'. When 'unfinishedPart' is
 339    given as a target specifier (once or twice), this instead prevents
 340    the decision from being automatically marked as 'explored' when we
 341    leave it.
 342
 343- 'R' / 'revert': Reverts some or all of the current state to a
 344    previously saved state. Saving happens via the 'save' effect type,
 345    but reverting is an explicit action. The first argument names the
 346    save slot to revert to, while the rest are interpreted as the set of
 347    aspects to revert (see `base.revertedState`).
 348
 349- 'F' / 'fulfills': Specifies a requirement and a capability, and adds
 350    an equivalence to the current graph such that if that requirement is
 351    fulfilled, the specified capability is considered to be active. This
 352    allows for later discovery of one or more powers which allow
 353    traversal of previously-marked transitions whose true requirements
 354    were unknown when they were discovered.
 355
 356- '@' / 'relative': Specifies a decision to be treated as the 'current
 357    decision' without actually setting the position there. Use the
 358    marker alone or twice (default '@ @') to enter relative mode at the
 359    current decision (or to exit it). Until used to reverse this effect,
 360    all position-changing entries change this relative position value
 361    instead of the actual position in the graph, and updates are applied
 362    to the current graph without creating new exploration steps or
 363    applying any effects. Useful for doing things like noting information
 364    about far-away locations disclosed in a cutscene. Can target a
 365    transition at the current node by specifying 'transitionPart' or two
 366    arguments for a decision and transition. In that case, the specified
 367    transition is counted as the 'most-recent-transition' for entry
 368    purposes and the same relative mode is entered.
 369"""
 370
 371JournalTargetType = Literal[
 372    'decisionPart',
 373    'transitionPart',
 374    'reciprocalPart',
 375    'bothPart',
 376    'zonePart',
 377    'actionPart',
 378    'endingPart',
 379    'unfinishedPart',
 380]
 381"""
 382The different parts that an entry can target. The signifiers for these
 383target types will be concatenated with a journal entry signifier in some
 384cases. For example, by default 'g' as an entry type means 'tag', and 't'
 385as a target type means 'transition'. So 'gt' as an entry type means 'tag
 386transition' and applies the relevant tag to the most-recently-created
 387transition instead of the most-recently-created decision. The
 388`targetSeparator` character (default '@') is used to combine an entry
 389type with a target type when the entry type is written without
 390abbreviation. In that case, the target specifier may drop the suffix
 391'Part' (e.g., `tag@transition` in place of `gt`). The available target
 392parts are each valid only for specific entry types. The target parts are:
 393
 394- 'decisionPart' - Use to specify that the entry applies to a decision
 395    when it would normally apply to something else.
 396- 'transitionPart' - Use to specify that the entry applies to a
 397    transition instead of a decision.
 398- 'reciprocalPart' - Use to specify that the entry applies to a
 399    reciprocal transition instead of a decision or the normal
 400    transition.
 401- 'bothPart' - Use to specify that the entry applies to both of two
 402    possibilities, such as to a transition and its reciprocal.
 403- 'zonePart' - Use for re-zoning to indicate the hierarchy level. May
 404    be repeated; each instance increases the hierarchy level by 1
 405    starting from 0. In the long form to specify a hierarchy level, use
 406    the letter 'z' followed by an integer, as in 'z3' for level 3. Also
 407    used in the same way for tagging or annotating zones.
 408- 'actionPart' - Use for the 'observe' or 'retrace' entries to specify
 409    that the observed/retraced transition is an action (i.e., its
 410    destination is the same as its source) rather than a real transition
 411    (whose destination would be a new, unknown node).
 412- 'endingPart' - Use only for the 'observe' entry to specify that the
 413    observed transition goes to an ending rather than a normal decision.
 414- 'unfinishedPart' - Use only for the 'status' entry (and use either
 415    once or twice in the short form) to specify that a decision should
 416    NOT be finalized when we leave it.
 417
 418The entry types where a target specifier can be applied are:
 419
 420- 'requirement': By default these are applied to transitions, but the
 421    'reciprocalPart' target can be used to apply to a reciprocal
 422    instead. Use `bothPart` to apply the same requirement to both the
 423    transition and its reciprocal.
 424- 'effect': Same as 'requirement'.
 425- 'apply': Same as 'effect' (and see above).
 426- 'tag': Applies the tag to the specified target instead of the current
 427    exploration step. When targeting zones using 'zonePart', if there are
 428    multiple zones that apply at a certain hierarchy level we target the
 429    smallest one (breaking ties alphabetically by name). TODO: target the
 430    most-recently asserted one. The 'zonePart' may be repeated to specify
 431    a hierarchy level, as in 'gzz' for level 1 instead of level 0, and
 432    you may also use 'z' followed by an integer, as in 'gz3' for level 3.
 433- 'annotation': Same as 'tag'.
 434- 'unify': By default applies to a decision, but can be applied to a
 435    transition or reciprocal instead.
 436- 'extinguish': By default applies to a transition and its reciprocal,
 437    but can be applied to just one or the other, or to a decision.
 438- 'relative': Only 'transition' applies here and changes the
 439    most-recent-transition value when entering relative mode instead of
 440    just changing the current-decision value. Can be used within
 441    relative mode to pick out an existing transition as well.
 442- 'zone': This is the main place where the 'zonePart' target type
 443    applies, and it can actually be applied as many times as you want.
 444    Each application makes the zone specified apply to a higher level in
 445    the hierarchy of zones, so that instead of swapping the level-0 zone
 446    using 'z', the level-1 zone can be changed using 'zz' or the level 2
 447    zone using 'zzz', etc. In lieu of using multiple 'z's, you can also
 448    just write one 'z' followed by an integer for the level you want to
 449    use (e.g., z0 for a level-0 zone, or z1 for a level-1 zone). When
 450    using a long-form entry type, the target may be given as the string
 451    'zone' in which case the level-1 zone is used. To use a different
 452    zone level with a long-form entry type, repeat the 'z' followed by an
 453    integer, or use multiple 'z's.
 454- 'observe': Uses the 'actionPart' and 'endingPart' target types, and
 455    those are the only applicable target types.  Applying `actionPart`
 456    turns the observed transition into an action; applying `endingPart`
 457    turns it into an transition to an ending.
 458- 'retrace': Uses 'actionPart' or not to distinguish what kind of
 459    transition is being taken. Riases a `JournalParseError` if the type
 460    of edge (external vs. self destination) doesn't match this
 461    distinction.
 462- 'status': The only place where 'unfinishedPart' target type applies.
 463    Using it once or twice signifies that the decision should NOT be
 464    marked as completely-explored when we leave it.
 465"""
 466
 467JournalInfoType = Literal[
 468    'on',
 469    'off',
 470    'domainFocalizationSingular',
 471    'domainFocalizationPlural',
 472    'domainFocalizationSpreading',
 473    'commonContext',
 474    'comment',
 475    'unknownItem',
 476    'notApplicable',
 477    'exclusiveDomain',
 478    'targetSeparator',
 479    'reciprocalSeparator',
 480    'transitionAtDecision',
 481    'blockDelimiters',
 482]
 483"""
 484Represents a part of the journal syntax which isn't an entry type but is
 485used to mark something else. For example, the character denoting an
 486unknown item. The available values are:
 487
 488- 'on' / 'off': Used to indicate on/off status for preferences.
 489- 'domainFocalizationSingular' / 'domainFocalizationPlural'
 490  / 'domainFocalizationSpreading': Used as markers after a domain for
 491  the `core.DomainFocalization` values.
 492- 'commonContext': Used with 'context' in place of a
 493    `core.FocalContextName` to indicate that we are targeting the common
 494    focal context.
 495- 'comment': Indicates extraneous text that should be ignored by the
 496    journal parser. Note that tags and/or annotations should usually be
 497    used to apply comments that will be accessible when viewing the
 498    exploration object.
 499- 'unknownItem': Used in place of an item name to indicate that
 500    although an item is known to exist, it's not yet know what that item
 501    is. Note that when journaling, you should make up names for items
 502    you pick up, even if you don't know what they do yet. This notation
 503    should only be used for items that you haven't picked up because
 504    they're inaccessible, and despite being apparent, you don't know
 505    what they are because they come in a container (e.g., you see a
 506    sealed chest, but you don't know what's in it).
 507- 'notApplicable': Used in certain positions to indicate that something
 508    is missing entirely or otherwise that a piece of information
 509    normally supplied is unnecessary. For example, when used as the
 510    reciprocal name for a transition, this will cause the reciprocal
 511    transition to be deleted entirely, or when used before a domain name
 512    with the 'domain' entry type it deactivates that domain. TODO
 513- 'exclusiveDomain': Used to indicate that a domain being activated
 514    should deactivate other domains, instead of being activated along
 515    with them.
 516- 'targetSeparator': Used in long-form entry types to separate the entry
 517    type from a target specifier when a target is specified. Default is
 518    '@'. For example, a 'gt' entry (tag transition) would be expressed
 519    as 'tag@transition' in the long form.
 520- 'reciprocalSeparator': Used to indicate, within a requirement or a
 521    tag set, a separation between requirements/tags to be applied to the
 522    forward direction and requirements/tags to be applied to the reverse
 523    direction. Not always applicable (e.g., actions have no reverse
 524    direction).
 525- 'transitionAtDecision' Used to separate a decision name from a
 526    transition name when identifying a specific transition.
 527- 'blockDelimiters' Two characters used to delimit the start and end of
 528    a block of entries. Used for things like edit effects.
 529"""
 530
 531JournalMarkerType = Union[
 532    JournalEntryType,
 533    JournalTargetType,
 534    base.DecisionType,
 535    JournalInfoType
 536]
 537"Any journal marker type."
 538
 539
 540JournalFormat = Dict[JournalMarkerType, str]
 541"""
 542A journal format is specified using a dictionary with keys that denote
 543journal marker types and values which are one-to-several-character
 544strings indicating the markup used for that entry/info type.
 545"""
 546
 547DEFAULT_FORMAT: JournalFormat = {
 548    # Toggles
 549    'preference': 'P',
 550
 551    # Alias handling
 552    'alias': '=',
 553    'custom': '>',
 554
 555    # Debugging
 556    'DEBUG': '?',
 557
 558    # Core entry types
 559    'START': 'S',
 560    'explore': 'x',
 561    'return': 'r',
 562    'action': 'a',
 563    'retrace': 't',
 564    'wait': 'w',
 565    'warp': 'p',
 566    'observe': 'o',
 567    'END': 'E',
 568    'mechanism': 'm',
 569
 570    # Transition properties
 571    'requirement': 'q',
 572    'effect': 'e',
 573    'apply': 'A',
 574
 575    # Tags & annotations
 576    'tag': 'g',
 577    'annotate': 'n',
 578
 579    # Context management
 580    'context': 'c',
 581    'domain': 'd',
 582    'focus': 'f',
 583    'zone': 'z',
 584
 585    # Revisions
 586    'unify': 'u',
 587    'obviate': 'v',
 588    'extinguish': 'X',
 589    'complicate': 'C',
 590
 591    # Exploration status modifiers
 592    'status': '.',
 593
 594    # Reversion
 595    'revert': 'R',
 596
 597    # Capability discovery
 598    'fulfills': 'F',
 599
 600    # Relative mode
 601    'relative': '@',
 602
 603    # Target specifiers
 604    'decisionPart': 'd',
 605    'transitionPart': 't',
 606    'reciprocalPart': 'r',
 607    'bothPart': 'b',
 608    'zonePart': 'z',
 609    'actionPart': 'a',
 610    'endingPart': 'E',
 611    'unfinishedPart': '.',
 612
 613    # Decision types
 614    'pending': '?',
 615    'active': '.',
 616    'unintended': '!',
 617    'imposed': '>',
 618    'consequence': '~',
 619
 620    # Info markers
 621    'on': 'on',
 622    'off': 'off',
 623    'domainFocalizationSingular': 'singular',
 624    'domainFocalizationPlural': 'plural',
 625    'domainFocalizationSpreading': 'spreading',
 626    'commonContext': '*',
 627    'comment': '#',
 628    'unknownItem': '?',
 629    'notApplicable': '-',
 630    'exclusiveDomain': '>',
 631    'reciprocalSeparator': '/',
 632    'targetSeparator': '@',
 633    'transitionAtDecision': '%',
 634    'blockDelimiters': '[]',
 635}
 636"""
 637The default `JournalFormat` dictionary.
 638"""
 639
 640
 641DebugAction = Literal[
 642    'here',
 643    'transition',
 644    'destinations',
 645    'steps',
 646    'decisions',
 647    'active',
 648    'primary',
 649    'saved',
 650    'inventory',
 651    'mechanisms',
 652    'equivalences',
 653]
 654"""
 655The different kinds of debugging commands.
 656"""
 657
 658
 659class JournalParseFormat(parsing.ParseFormat):
 660    """
 661    A ParseFormat manages the mapping from markers to entry types and
 662    vice versa.
 663    """
 664    def __init__(
 665        self,
 666        formatDict: parsing.Format = parsing.DEFAULT_FORMAT,
 667        journalMarkers: JournalFormat = DEFAULT_FORMAT
 668    ):
 669        """
 670        Sets up the parsing format. Accepts base and/or journal format
 671        dictionaries, but they both have defaults (see `DEFAULT_FORMAT`
 672        and `parsing.DEFAULT_FORMAT`). Raises a `ValueError` unless the
 673        keys of the format dictionaries exactly match the required
 674        values (the `parsing.Lexeme` values for the base format and the
 675        `JournalMarkerType` values for the journal format).
 676        """
 677        super().__init__(formatDict)
 678        self.journalMarkers: JournalFormat = journalMarkers
 679
 680        # Build comment regular expression
 681        self.commentRE = re.compile(
 682            self.journalMarkers.get('comment', '#') + '.*$',
 683            flags=re.MULTILINE
 684        )
 685
 686        # Get block delimiters
 687        blockDelimiters = journalMarkers.get('blockDelimiters', '[]')
 688        if len(blockDelimiters) != 2:
 689            raise ValueError(
 690                f"Block delimiters must be a length-2 string containing"
 691                f" the start and end markers. Got: {blockDelimiters!r}."
 692            )
 693        blockStart = blockDelimiters[0]
 694        blockEnd = blockDelimiters[1]
 695        self.blockStart = blockStart
 696        self.blockEnd = blockEnd
 697
 698        # Add backslash for literal if it's an RE special char
 699        if blockStart in '[]()*.?^$&+\\':
 700            blockStart = '\\' + blockStart
 701        if blockEnd in '[]()*.?^$&+\\':
 702            blockEnd = '\\' + blockEnd
 703
 704        # Build block start and end regular expressions
 705        self.blockStartRE = re.compile(
 706            blockStart + r'\s*$',
 707            flags=re.MULTILINE
 708        )
 709        self.blockEndRE = re.compile(
 710            r'^\s*' + blockEnd,
 711            flags=re.MULTILINE
 712        )
 713
 714        # Check that journalMarkers doesn't have any extra keys
 715        markerTypes = (
 716            get_args(JournalEntryType)
 717          + get_args(base.DecisionType)
 718          + get_args(JournalTargetType)
 719          + get_args(JournalInfoType)
 720        )
 721        for key in journalMarkers:
 722            if key not in markerTypes:
 723                raise ValueError(
 724                    f"Format dict has key {key!r} which is not a"
 725                    f" recognized entry or info type."
 726                )
 727
 728        # Check completeness of formatDict
 729        for mtype in markerTypes:
 730            if mtype not in journalMarkers:
 731                raise ValueError(
 732                    f"Journal markers dict is missing an entry for"
 733                    f" marker type {mtype!r}."
 734                )
 735
 736        # Build reverse dictionaries from markers to entry types and
 737        # from markers to target types (no reverse needed for info
 738        # types).
 739        self.entryMap: Dict[str, JournalEntryType] = {}
 740        self.targetMap: Dict[str, JournalTargetType] = {}
 741        entryTypes = set(get_args(JournalEntryType))
 742        targetTypes = set(get_args(JournalTargetType))
 743
 744        # Check for duplicates and create reverse maps
 745        for name, marker in journalMarkers.items():
 746            if name in entryTypes:
 747                # Duplicates not allowed among entry types
 748                if marker in self.entryMap:
 749                    raise ValueError(
 750                        f"Format dict entry for {name!r} duplicates"
 751                        f" previous format dict entry for"
 752                        f" {self.entryMap[marker]!r}."
 753                    )
 754
 755                # Map markers to entry types
 756                self.entryMap[marker] = cast(JournalEntryType, name)
 757            elif name in targetTypes:
 758                # Duplicates not allowed among entry types
 759                if marker in self.targetMap:
 760                    raise ValueError(
 761                        f"Format dict entry for {name!r} duplicates"
 762                        f" previous format dict entry for"
 763                        f" {self.targetMap[marker]!r}."
 764                    )
 765
 766                # Map markers to entry types
 767                self.targetMap[marker] = cast(JournalTargetType, name)
 768
 769            # else ignore it since it's an info type
 770
 771    def markers(self) -> List[str]:
 772        """
 773        Returns the list of all entry-type markers (but not other kinds
 774        of markers), sorted from longest to shortest to help avoid
 775        ambiguities when matching.
 776        """
 777        entryTypes = get_args(JournalEntryType)
 778        return sorted(
 779            (
 780                m
 781                for (et, m) in self.journalMarkers.items()
 782                if et in entryTypes
 783            ),
 784            key=lambda m: -len(m)
 785        )
 786
 787    def markerFor(self, markerType: JournalMarkerType) -> str:
 788        """
 789        Returns the marker for the specified entry/info/effect/etc.
 790        type.
 791        """
 792        return self.journalMarkers[markerType]
 793
 794    def determineEntryType(self, entryBits: List[str]) -> Tuple[
 795        JournalEntryType,
 796        base.DecisionType,
 797        Union[None, JournalTargetType, Tuple[JournalTargetType, int]],
 798        List[str]
 799    ]:
 800        """
 801        Given a sequence of strings that specify a command, returns a
 802        tuple containing the entry type, decision type, target part, and
 803        list of arguments for that command. The default decision type is
 804        'active' but others can be specified with decision type
 805        modifiers. If no target type was included, the third entry of
 806        the return value will be `None`, and in the special case of
 807        zones, it will be an integer indicating the hierarchy level
 808        according to how many times the 'zonePart' target specifier was
 809        present, default 0.
 810
 811        For example:
 812
 813        >>> pf = JournalParseFormat()
 814        >>> pf.determineEntryType(['retrace', 'transition'])
 815        ('retrace', 'active', None, ['transition'])
 816        >>> pf.determineEntryType(['t', 'transition'])
 817        ('retrace', 'active', None, ['transition'])
 818        >>> pf.determineEntryType(['observe@action', 'open'])
 819        ('observe', 'active', 'actionPart', ['open'])
 820        >>> pf.determineEntryType(['oa', 'open'])
 821        ('observe', 'active', 'actionPart', ['open'])
 822        >>> pf.determineEntryType(['!explore', 'down', 'pit', 'up'])
 823        ('explore', 'unintended', None, ['down', 'pit', 'up'])
 824        >>> pf.determineEntryType(['imposed/explore', 'down', 'pit', 'up'])
 825        ('explore', 'imposed', None, ['down', 'pit', 'up'])
 826        >>> pf.determineEntryType(['~x', 'down', 'pit', 'up'])
 827        ('explore', 'consequence', None, ['down', 'pit', 'up'])
 828        >>> pf.determineEntryType(['>x', 'down', 'pit', 'up'])
 829        ('explore', 'imposed', None, ['down', 'pit', 'up'])
 830        >>> pf.determineEntryType(['gzz', 'tag'])
 831        ('tag', 'active', ('zonePart', 1), ['tag'])
 832        >>> pf.determineEntryType(['gz4', 'tag'])
 833        ('tag', 'active', ('zonePart', 4), ['tag'])
 834        >>> pf.determineEntryType(['zone@z2', 'ZoneName'])
 835        ('zone', 'active', ('zonePart', 2), ['ZoneName'])
 836        >>> pf.determineEntryType(['zzz', 'ZoneName'])
 837        ('zone', 'active', ('zonePart', 2), ['ZoneName'])
 838        """
 839        # Get entry specifier
 840        entrySpecifier = entryBits[0]
 841        entryArgs = entryBits[1:]
 842
 843        # Defaults
 844        entryType: Optional[JournalEntryType] = None
 845        entryTarget: Union[
 846            None,
 847            JournalTargetType,
 848            Tuple[JournalTargetType, int]
 849        ] = None
 850        entryDecisionType: base.DecisionType = 'active'
 851
 852        # Check for a decision type specifier and process+remove it
 853        for decisionType in get_args(base.DecisionType):
 854            marker = self.markerFor(decisionType)
 855            lm = len(marker)
 856            if (
 857                entrySpecifier.startswith(marker)
 858            and len(entrySpecifier) > lm
 859            ):
 860                entrySpecifier = entrySpecifier[lm:]
 861                entryDecisionType = decisionType
 862                break
 863            elif entrySpecifier.startswith(
 864                decisionType + self.markerFor('reciprocalSeparator')
 865            ):
 866                entrySpecifier = entrySpecifier[len(decisionType) + 1:]
 867                entryDecisionType = decisionType
 868                break
 869
 870        # Sets of valid types and targets
 871        validEntryTypes: Set[JournalEntryType] = set(
 872            get_args(JournalEntryType)
 873        )
 874        validEntryTargets: Set[JournalTargetType] = set(
 875            get_args(JournalTargetType)
 876        )
 877
 878        # Look for a long-form entry specifier with an @ sign separating
 879        # the entry type from the entry target
 880        targetMarker = self.markerFor('targetSeparator')
 881        if (
 882            targetMarker in entrySpecifier
 883        and not entrySpecifier.startswith(targetMarker)
 884            # Because the targetMarker is also a valid entry type!
 885        ):
 886            specifierBits = entrySpecifier.split(targetMarker)
 887            if len(specifierBits) != 2:
 888                raise JournalParseError(
 889                    f"When a long-form entry specifier contains a"
 890                    f" target separator, it must contain exactly one (to"
 891                    f" split the entry type from the entry target). We got"
 892                    f" {entrySpecifier!r}."
 893                )
 894            entryTypeGuess: str
 895            entryTargetGuess: Optional[str]
 896            entryTypeGuess, entryTargetGuess = specifierBits
 897            if entryTypeGuess not in validEntryTypes:
 898                raise JournalParseError(
 899                    f"Invalid long-form entry type: {entryType!r}"
 900                )
 901            else:
 902                entryType = cast(JournalEntryType, entryTypeGuess)
 903
 904            # Special logic for zone part
 905            handled = False
 906            if entryType in ('zone', 'tag', 'annotate'):
 907                handled = True
 908                if entryType == 'zone' and entryTargetGuess.isdigit():
 909                    entryTarget = ('zonePart', int(entryTargetGuess))
 910                elif entryTargetGuess == 'zone':
 911                    entryTarget = ('zonePart', 1 if entryType == 'zone' else 0)
 912                elif (
 913                    entryTargetGuess.startswith('z')
 914                and entryTargetGuess[1:].isdigit()
 915                ):
 916                    entryTarget = ('zonePart', int(entryTargetGuess[1:]))
 917                elif (
 918                    len(entryTargetGuess) > 0
 919                and set(entryTargetGuess) != {'z'}
 920                ):
 921                    if entryType == 'zone':
 922                        raise JournalParseError(
 923                            f"Invalid target specifier for"
 924                            f" zone entry:\n{entryTargetGuess}"
 925                        )
 926                    else:
 927                        handled = False
 928                else:
 929                    entryTarget = ('zonePart', len(entryTargetGuess))
 930
 931            if not handled:
 932                if entryTargetGuess + 'Part' in validEntryTargets:
 933                    entryTarget = cast(
 934                        JournalTargetType,
 935                        entryTargetGuess + 'Part'
 936                    )
 937                else:
 938                    origGuess = entryTargetGuess
 939                    entryTargetGuess = self.targetMap.get(
 940                        entryTargetGuess,
 941                        entryTargetGuess
 942                    )
 943                    if entryTargetGuess not in validEntryTargets:
 944                        raise JournalParseError(
 945                            f"Invalid long-form entry target:"
 946                            f" {origGuess!r}"
 947                        )
 948                    else:
 949                        entryTarget = cast(
 950                            JournalTargetType,
 951                            entryTargetGuess
 952                        )
 953
 954        elif entrySpecifier in validEntryTypes:
 955            # Might be a long-form specifier without a separator
 956            entryType = cast(JournalEntryType, entrySpecifier)
 957            entryTarget = None
 958            if entryType == 'zone':
 959                entryTarget = ('zonePart', 0)
 960
 961        else:  # parse a short-form entry specifier
 962            typeSpecifier = entrySpecifier[0]
 963            if typeSpecifier not in self.entryMap:
 964                raise JournalParseError(
 965                    f"Entry does not begin with a recognized entry"
 966                    f" marker:\n{entryBits}"
 967                )
 968            entryType = self.entryMap[typeSpecifier]
 969
 970            # Figure out the entry target from second+ character(s)
 971            targetSpecifiers = entrySpecifier[1:]
 972            specifiersSet = set(targetSpecifiers)
 973            if entryType == 'zone':
 974                if targetSpecifiers.isdigit():
 975                    entryTarget = ('zonePart', int(targetSpecifiers))
 976                elif (
 977                    len(specifiersSet) > 0
 978                and specifiersSet != {self.journalMarkers['zonePart']}
 979                ):
 980                    raise JournalParseError(
 981                        f"Invalid target specifier for zone:\n{entryBits}"
 982                    )
 983                else:
 984                    entryTarget = ('zonePart', len(targetSpecifiers))
 985            elif entryType == 'status':
 986                if len(targetSpecifiers) > 0:
 987                    if any(
 988                        x != self.journalMarkers['unfinishedPart']
 989                        for x in targetSpecifiers
 990                    ):
 991                        raise JournalParseError(
 992                            f"Invalid target specifier for"
 993                            f" status:\n{entryBits}"
 994                        )
 995                    entryTarget = 'unfinishedPart'
 996                else:
 997                    entryTarget = None
 998            elif len(targetSpecifiers) > 0:
 999                if (
1000                    targetSpecifiers[1:].isdigit()
1001                and targetSpecifiers[0] in self.targetMap
1002                ):
1003                    entryTarget = (
1004                        self.targetMap[targetSpecifiers[0]],
1005                        int(targetSpecifiers[1:])
1006                    )
1007                elif len(specifiersSet) > 1:
1008                    raise JournalParseError(
1009                        f"Entry has too many target specifiers:\n{entryBits}"
1010                    )
1011                else:
1012                    specifier = list(specifiersSet)[0]
1013                    copies = len(targetSpecifiers)
1014                    if specifier not in self.targetMap:
1015                        raise JournalParseError(
1016                            f"Unrecognized target specifier in:\n{entryBits}"
1017                        )
1018                    entryTarget = self.targetMap[specifier]
1019                    if copies > 1:
1020                        entryTarget = (entryTarget, copies - 1)
1021        # else entryTarget remains None
1022
1023        return (entryType, entryDecisionType, entryTarget, entryArgs)
1024
1025    def argsString(self, pieces: List[str]) -> str:
1026        """
1027        Recombines pieces of a journal argument (such as those produced
1028        by `unparseEffect`) into a single string. When there are
1029        multi-line or space-containing pieces, this adds block start/end
1030        delimiters and indents the piece if it's multi-line.
1031        """
1032        result = ''
1033        for piece in pieces:
1034            if '\n' in piece:
1035                result += (
1036                    f" {self.blockStart}\n"
1037                    f"{textwrap.indent(piece, '  ')}"
1038                    f"{self.blockEnd}"
1039                )
1040            elif ' ' in piece:
1041                result += f" {self.blockStart}{piece}{self.blockEnd}"
1042            else:
1043                result += ' ' + piece
1044
1045        return result[1:]  # chop off extra initial space
1046
1047    def removeComments(self, text: str) -> str:
1048        """
1049        Given one or more lines from a journal, removes all comments from
1050        it/them. Any '#' and any following characters through the end of
1051        a line counts as a comment.
1052
1053        Returns the text without comments.
1054
1055        Example:
1056
1057        >>> pf = JournalParseFormat()
1058        >>> pf.removeComments('abc # 123')
1059        'abc '
1060        >>> pf.removeComments('''\\
1061        ... line one # comment
1062        ... line two # comment
1063        ... line three
1064        ... line four # comment
1065        ... ''')
1066        'line one \\nline two \\nline three\\nline four \\n'
1067        """
1068        return self.commentRE.sub('', text)
1069
1070    def findBlockEnd(self, string: str, startIndex: int) -> int:
1071        """
1072        Given a string and a start index where a block open delimiter
1073        is, returns the index within the string of the matching block
1074        closing delimiter.
1075
1076        There are two possibilities: either both the opening and closing
1077        delimiter appear on the same line, or the block start appears at
1078        the end of a line (modulo whitespce) and the block end appears
1079        at the beginning of a line (modulo whitespace). Any other
1080        configuration is invalid and may lead to a `JournalParseError`.
1081
1082        Note that blocks may be nested within each other, including
1083        nesting single-line blocks in a multi-line block. It's also
1084        possible for several single-line blocks to appear on the same
1085        line.
1086
1087        Examples:
1088
1089        >>> pf = JournalParseFormat()
1090        >>> pf.findBlockEnd('[ A ]', 0)
1091        4
1092        >>> pf.findBlockEnd('[ A ] [ B ]', 0)
1093        4
1094        >>> pf.findBlockEnd('[ A ] [ B ]', 6)
1095        10
1096        >>> pf.findBlockEnd('[ A [ B ] ]', 0)
1097        10
1098        >>> pf.findBlockEnd('[ A [ B ] ]', 4)
1099        8
1100        >>> pf.findBlockEnd('[ [ B ]', 0)
1101        Traceback (most recent call last):
1102        ...
1103        exploration.journal.JournalParseError...
1104        >>> pf.findBlockEnd('[\\nABC\\n]', 0)
1105        6
1106        >>> pf.findBlockEnd('[\\nABC]', 0)  # End marker must start line
1107        Traceback (most recent call last):
1108        ...
1109        exploration.journal.JournalParseError...
1110        >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n  ]', 0)
1111        19
1112        >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n  ]', 9)
1113        15
1114        >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n  ]', 0)
1115        19
1116        >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n  ]', 9)
1117        15
1118        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 0)
1119        24
1120        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 11)
1121        22
1122        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 16)
1123        18
1124        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H \\n  ]\\n]', 16)
1125        Traceback (most recent call last):
1126        ...
1127        exploration.journal.JournalParseError...
1128        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n\\n]', 0)
1129        Traceback (most recent call last):
1130        ...
1131        exploration.journal.JournalParseError...
1132        """
1133        # Find end of the line that the block opens on
1134        try:
1135            endOfLine = string.index('\n', startIndex)
1136        except ValueError:
1137            endOfLine = len(string)
1138
1139        # Determine if this is a single-line or multi-line block based
1140        # on the presence of *anything* after the opening delimiter
1141        restOfLine = string[startIndex + 1:endOfLine]
1142        if restOfLine.strip() != '':  # A single-line block
1143            level = 1
1144            for restIndex, char in enumerate(restOfLine):
1145                if char == self.blockEnd:
1146                    level -= 1
1147                    if level <= 0:
1148                        break
1149                elif char == self.blockStart:
1150                    level += 1
1151
1152            if level == 0:
1153                return startIndex + 1 + restIndex
1154            else:
1155                raise JournalParseError(
1156                    f"Got to end of line in single-line block without"
1157                    f" finding the matching end-of-block marker."
1158                    f" Remainder of line is:\n  {restOfLine!r}"
1159                )
1160
1161        else:  # It's a multi-line block
1162            level = 1
1163            index = startIndex + 1
1164            while level > 0 and index < len(string):
1165                nextStart = self.blockStartRE.search(string, index)
1166                nextEnd = self.blockEndRE.search(string, index)
1167                if nextEnd is None:
1168                    break  # no end in sight; level won't be 0
1169                elif (
1170                    nextStart is None
1171                 or nextStart.start() > nextEnd.start()
1172                ):
1173                    index = nextEnd.end()
1174                    level -= 1
1175                    if level <= 0:
1176                        break
1177                else:  # They cannot be equal
1178                    index = nextStart.end()
1179                    level += 1
1180
1181            if level == 0:
1182                if nextEnd is None:
1183                    raise RuntimeError(
1184                        "Parsing got to level 0 with no valid end"
1185                        " match."
1186                    )
1187                return nextEnd.end() - 1
1188            else:
1189                raise JournalParseError(
1190                    f"Got to the end of the entire string and didn't"
1191                    f" find a matching end-of-block marker. Started at"
1192                    f" index {startIndex}."
1193                )
1194
1195
1196#-------------------#
1197# Errors & Warnings #
1198#-------------------#
1199
1200class JournalParseError(ValueError):
1201    """
1202    Represents a error encountered when parsing a journal.
1203    """
1204    pass
1205
1206
1207class LocatedJournalParseError(JournalParseError):
1208    """
1209    An error during journal parsing that includes additional location
1210    information.
1211    """
1212    def __init__(
1213        self,
1214        src: str,
1215        index: Optional[int],
1216        cause: Exception
1217    ) -> None:
1218        """
1219        In addition to the underlying error, the journal source text and
1220        the index within that text where the error occurred are
1221        required.
1222        """
1223        super().__init__("localized error")
1224        self.src = src
1225        self.index = index
1226        self.cause = cause
1227
1228    def __str__(self) -> str:
1229        """
1230        Includes information about the location of the error and the
1231        line it appeared on.
1232        """
1233        ec = errorContext(self.src, self.index)
1234        errorCM = textwrap.indent(errorContextMessage(ec), '  ')
1235        return (
1236            f"\n{errorCM}"
1237            f"\n  Error is:"
1238            f"\n{type(self.cause).__name__}: {self.cause}"
1239        )
1240
1241
1242def errorContext(
1243    string: str,
1244    index: Optional[int]
1245) -> Optional[Tuple[str, int, int]]:
1246    """
1247    Returns the line of text, the line number, and the character within
1248    that line for the given absolute index into the given string.
1249    Newline characters count as the last character on their line. Lines
1250    and characters are numbered starting from 1.
1251
1252    Returns `None` for out-of-range indices.
1253
1254    Examples:
1255
1256    >>> errorContext('a\\nb\\nc', 0)
1257    ('a\\n', 1, 1)
1258    >>> errorContext('a\\nb\\nc', 1)
1259    ('a\\n', 1, 2)
1260    >>> errorContext('a\\nbcd\\ne', 2)
1261    ('bcd\\n', 2, 1)
1262    >>> errorContext('a\\nbcd\\ne', 3)
1263    ('bcd\\n', 2, 2)
1264    >>> errorContext('a\\nbcd\\ne', 4)
1265    ('bcd\\n', 2, 3)
1266    >>> errorContext('a\\nbcd\\ne', 5)
1267    ('bcd\\n', 2, 4)
1268    >>> errorContext('a\\nbcd\\ne', 6)
1269    ('e', 3, 1)
1270    >>> errorContext('a\\nbcd\\ne', -1)
1271    ('e', 3, 1)
1272    >>> errorContext('a\\nbcd\\ne', -2)
1273    ('bcd\\n', 2, 4)
1274    >>> errorContext('a\\nbcd\\ne', 7) is None
1275    True
1276    >>> errorContext('a\\nbcd\\ne', 8) is None
1277    True
1278    """
1279    # Return None if no index is given
1280    if index is None:
1281        return None
1282
1283    # Convert negative to positive indices
1284    if index < 0:
1285        index = len(string) + index
1286
1287    # Return None for out-of-range indices
1288    if not 0 <= index < len(string):
1289        return None
1290
1291    # Count lines + look for start-of-line
1292    line = 1
1293    lineStart = 0
1294    for where, char in enumerate(string):
1295        if where >= index:
1296            break
1297        if char == '\n':
1298            line += 1
1299            lineStart = where + 1
1300
1301    try:
1302        endOfLine = string.index('\n', where)
1303    except ValueError:
1304        endOfLine = len(string)
1305
1306    return (string[lineStart:endOfLine + 1], line, index - lineStart + 1)
1307
1308
1309def errorContextMessage(context: Optional[Tuple[str, int, int]]) -> str:
1310    """
1311    Given an error context tuple (from `errorContext`) or possibly
1312    `None`, returns a string that can be used as part of an error
1313    message, identifying where the error occurred.
1314    """
1315    line: Union[int, str]
1316    pos: Union[int, str]
1317    if context is None:
1318        contextStr = "<context unavialable>"
1319        line = "?"
1320        pos = "?"
1321    else:
1322        contextStr, line, pos = context
1323        contextStr = contextStr.rstrip('\n')
1324    return (
1325        f"In journal on line {line} near character {pos}:"
1326        f"  {contextStr}"
1327    )
1328
1329
1330class JournalParseWarning(Warning):
1331    """
1332    Represents a warning encountered when parsing a journal.
1333    """
1334    pass
1335
1336
1337class PathEllipsis:
1338    """
1339    Represents part of a path which has been omitted from a journal and
1340    which should therefore be inferred.
1341    """
1342    pass
1343
1344
1345#-----------------#
1346# Parsing manager #
1347#-----------------#
1348
1349class ObservationContext(TypedDict):
1350    """
1351    The context for an observation, including which context (common or
1352    active) is being used, which domain we're focused on, which focal
1353    point is being modified for plural-focalized domains, and which
1354    decision and transition within the current domain are most relevant
1355    right now.
1356    """
1357    context: base.ContextSpecifier
1358    domain: base.Domain
1359    # TODO: Per-domain focus/decision/transitions?
1360    focus: Optional[base.FocalPointName]
1361    decision: Optional[base.DecisionID]
1362    transition: Optional[Tuple[base.DecisionID, base.Transition]]
1363
1364
1365def observationContext(
1366    context: base.ContextSpecifier = "active",
1367    domain: base.Domain = base.DEFAULT_DOMAIN,
1368    focus: Optional[base.FocalPointName] = None,
1369    decision: Optional[base.DecisionID] = None,
1370    transition: Optional[Tuple[base.DecisionID, base.Transition]] = None
1371) -> ObservationContext:
1372    """
1373    Creates a default/empty `ObservationContext`.
1374    """
1375    return {
1376        'context': context,
1377        'domain': domain,
1378        'focus': focus,
1379        'decision': decision,
1380        'transition': transition
1381    }
1382
1383
1384class ObservationPreferences(TypedDict):
1385    """
1386    Specifies global preferences for exploration observation. Values are
1387    either strings or booleans. The keys are:
1388
1389    - 'reciprocals': A boolean specifying whether transitions should
1390        come with reciprocals by default. Normally this is `True`, but
1391        it can be set to `False` instead.
1392        TODO: implement this.
1393    - 'revertAspects': A set of strings specifying which aspects of the
1394        game state should be reverted when a 'revert' action is taken and
1395        specific aspects to revert are not specified. See
1396        `base.revertedState` for a list of the available reversion
1397        aspects.
1398    """
1399    reciprocals: bool
1400    revertAspects: Set[str]
1401
1402
1403def observationPreferences(
1404    reciprocals: bool=True,
1405    revertAspects: Optional[Set[str]] = None
1406) -> ObservationPreferences:
1407    """
1408    Creates an observation preferences dictionary, using default values
1409    for any preferences not specified as arguments.
1410    """
1411    return {
1412        'reciprocals': reciprocals,
1413        'revertAspects': (
1414            revertAspects
1415            if revertAspects is not None
1416            else set()
1417        )
1418    }
1419
1420
1421class JournalObserver:
1422    """
1423    Keeps track of extra state needed when parsing a journal in order to
1424    produce a `core.DiscreteExploration` object. The methods of this
1425    class act as an API for constructing explorations that have several
1426    special properties. The API is designed to allow journal entries
1427    (which represent specific observations/events during an exploration)
1428    to be directly accumulated into an exploration object, including
1429    entries which apply to things like the most-recent-decision or
1430    -transition.
1431
1432    You can use the `convertJournal` function to handle things instead,
1433    since that function creates and manages a `JournalObserver` object
1434    for you.
1435
1436    The basic usage is as follows:
1437
1438    1. Create a `JournalObserver`, optionally specifying a custom
1439        `ParseFormat`.
1440    2. Repeatedly either:
1441        * Call `record*` API methods corresponding to specific entries
1442            observed or...
1443        * Call `JournalObserver.observe` to parse one or more
1444            journal blocks from a string and call the appropriate
1445            methods automatically.
1446    3. Call `JournalObserver.getExploration` to retrieve the
1447        `core.DiscreteExploration` object that's been created.
1448
1449    You can just call `convertJournal` to do all of these things at
1450    once.
1451
1452    Notes:
1453
1454    - `JournalObserver.getExploration` may be called at any time to get
1455        the exploration object constructed so far, and that that object
1456        (unless it's `None`) will always be the same object (which gets
1457        modified as entries are recorded). Modifying this object
1458        directly is possible for making changes not available via the
1459        API, but must be done carefully, as there are important
1460        conventions around things like decision names that must be
1461        respected if the API functions need to keep working.
1462    - To get the latest graph or state, simply use the
1463        `core.DiscreteExploration.getSituation()` method of the
1464        `JournalObserver.getExploration` result.
1465
1466    ## Examples
1467
1468    >>> obs = JournalObserver()
1469    >>> e = obs.getExploration()
1470    >>> len(e) # blank starting state
1471    1
1472    >>> e.getActiveDecisions(0)  # no active decisions before starting
1473    set()
1474    >>> obs.definiteDecisionTarget()
1475    Traceback (most recent call last):
1476    ...
1477    exploration.core.MissingDecisionError...
1478    >>> obs.currentDecisionTarget() is None
1479    True
1480    >>> # We start by using the record* methods...
1481    >>> obs.recordStart("Start")
1482    >>> obs.definiteDecisionTarget()
1483    0
1484    >>> obs.recordObserve("bottom")
1485    >>> obs.definiteDecisionTarget()
1486    0
1487    >>> len(e) # blank + started states
1488    2
1489    >>> e.getActiveDecisions(1)
1490    {0}
1491    >>> obs.recordExplore("left", "West", "right")
1492    >>> obs.definiteDecisionTarget()
1493    2
1494    >>> len(e) # starting states + one step
1495    3
1496    >>> e.getActiveDecisions(1)
1497    {0}
1498    >>> e.movementAtStep(1)
1499    (0, 'left', 2)
1500    >>> e.getActiveDecisions(2)
1501    {2}
1502    >>> e.getActiveDecisions()
1503    {2}
1504    >>> e.getSituation().graph.nameFor(list(e.getActiveDecisions())[0])
1505    'West'
1506    >>> obs.recordRetrace("right")  # back at Start
1507    >>> obs.definiteDecisionTarget()
1508    0
1509    >>> len(e) # starting states + two steps
1510    4
1511    >>> e.getActiveDecisions(1)
1512    {0}
1513    >>> e.movementAtStep(1)
1514    (0, 'left', 2)
1515    >>> e.getActiveDecisions(2)
1516    {2}
1517    >>> e.movementAtStep(2)
1518    (2, 'right', 0)
1519    >>> e.getActiveDecisions(3)
1520    {0}
1521    >>> obs.recordRetrace("bad") # transition doesn't exist
1522    Traceback (most recent call last):
1523    ...
1524    exploration.journal.JournalParseError...
1525    >>> obs.definiteDecisionTarget()
1526    0
1527    >>> obs.recordObserve('right', 'East', 'left')
1528    >>> e.getSituation().graph.getTransitionRequirement('Start', 'right')
1529    ReqNothing()
1530    >>> obs.recordRequirement('crawl|small')
1531    >>> e.getSituation().graph.getTransitionRequirement('Start', 'right')
1532    ReqAny([ReqCapability('crawl'), ReqCapability('small')])
1533    >>> obs.definiteDecisionTarget()
1534    0
1535    >>> obs.currentTransitionTarget()
1536    (0, 'right')
1537    >>> obs.currentReciprocalTarget()
1538    (3, 'left')
1539    >>> g = e.getSituation().graph
1540    >>> print(g.namesListing(g).rstrip('\\n'))
1541      0 (Start)
1542      1 (_u.0)
1543      2 (West)
1544      3 (East)
1545    >>> # The use of relative mode to add remote observations
1546    >>> obs.relative('East')
1547    >>> obs.definiteDecisionTarget()
1548    3
1549    >>> obs.recordObserve('top_vent')
1550    >>> obs.recordRequirement('crawl')
1551    >>> obs.recordReciprocalRequirement('crawl')
1552    >>> obs.recordMechanism('East', 'door', 'closed')  # door starts closed
1553    >>> obs.recordAction('lever')
1554    >>> obs.recordTransitionConsequence(
1555    ...     [base.effect(set=("door", "open")), base.effect(deactivate=True)]
1556    ... )  # lever opens the door
1557    >>> obs.recordExplore('right_door', 'Outside', 'left_door')
1558    >>> obs.definiteDecisionTarget()
1559    5
1560    >>> obs.recordRequirement('door:open')
1561    >>> obs.recordReciprocalRequirement('door:open')
1562    >>> obs.definiteDecisionTarget()
1563    5
1564    >>> obs.exploration.getExplorationStatus('East')
1565    'noticed'
1566    >>> obs.exploration.hasBeenVisited('East')
1567    False
1568    >>> obs.exploration.getExplorationStatus('Outside')
1569    'noticed'
1570    >>> obs.exploration.hasBeenVisited('Outside')
1571    False
1572    >>> obs.relative() # leave relative mode
1573    >>> len(e) # starting states + two steps, no steps happen in relative mode
1574    4
1575    >>> obs.definiteDecisionTarget()  # out of relative mode; at Start
1576    0
1577    >>> g = e.getSituation().graph
1578    >>> g.getTransitionRequirement(
1579    ...     g.getDestination('East', 'top_vent'),
1580    ...     'return'
1581    ... )
1582    ReqCapability('crawl')
1583    >>> g.getTransitionRequirement('East', 'top_vent')
1584    ReqCapability('crawl')
1585    >>> g.getTransitionRequirement('East', 'right_door')
1586    ReqMechanism('door', 'open')
1587    >>> g.getTransitionRequirement('Outside', 'left_door')
1588    ReqMechanism('door', 'open')
1589    >>> print(g.namesListing(g).rstrip('\\n'))
1590      0 (Start)
1591      1 (_u.0)
1592      2 (West)
1593      3 (East)
1594      4 (_u.3)
1595      5 (Outside)
1596    >>> # Now we demonstrate the use of "observe"
1597    >>> e.getActiveDecisions()
1598    {0}
1599    >>> g.destinationsFrom(0)
1600    {'bottom': 1, 'left': 2, 'right': 3}
1601    >>> g.getDecision('Attic') is None
1602    True
1603    >>> obs.definiteDecisionTarget()
1604    0
1605    >>> obs.observe("\
1606o up Attic down\\n\
1607x up\\n\
1608   n at: Attic\\n\
1609o vent\\n\
1610q crawl")
1611    >>> g = e.getSituation().graph
1612    >>> print(g.namesListing(g).rstrip('\\n'))
1613      0 (Start)
1614      1 (_u.0)
1615      2 (West)
1616      3 (East)
1617      4 (_u.3)
1618      5 (Outside)
1619      6 (Attic)
1620      7 (_u.6)
1621    >>> g.destinationsFrom(0)
1622    {'bottom': 1, 'left': 2, 'right': 3, 'up': 6}
1623    >>> g.nameFor(list(e.getActiveDecisions())[0])
1624    'Attic'
1625    >>> g.getTransitionRequirement('Attic', 'vent')
1626    ReqCapability('crawl')
1627    >>> sorted(list(g.destinationsFrom('Attic').items()))
1628    [('down', 0), ('vent', 7)]
1629    >>> obs.definiteDecisionTarget()  # in the Attic
1630    6
1631    >>> obs.observe("\
1632a getCrawl\\n\
1633  At gain crawl\\n\
1634x vent East top_vent")  # connecting to a previously-observed transition
1635    >>> g = e.getSituation().graph
1636    >>> print(g.namesListing(g).rstrip('\\n'))
1637      0 (Start)
1638      1 (_u.0)
1639      2 (West)
1640      3 (East)
1641      5 (Outside)
1642      6 (Attic)
1643    >>> g.getTransitionRequirement('East', 'top_vent')
1644    ReqCapability('crawl')
1645    >>> g.nameFor(g.getDestination('Attic', 'vent'))
1646    'East'
1647    >>> g.nameFor(g.getDestination('East', 'top_vent'))
1648    'Attic'
1649    >>> len(e) # exploration, action, and return are each 1
1650    7
1651    >>> e.getActiveDecisions(3)
1652    {0}
1653    >>> e.movementAtStep(3)
1654    (0, 'up', 6)
1655    >>> e.getActiveDecisions(4)
1656    {6}
1657    >>> g.nameFor(list(e.getActiveDecisions(4))[0])
1658    'Attic'
1659    >>> e.movementAtStep(4)
1660    (6, 'getCrawl', 6)
1661    >>> g.nameFor(list(e.getActiveDecisions(5))[0])
1662    'Attic'
1663    >>> e.movementAtStep(5)
1664    (6, 'vent', 3)
1665    >>> g.nameFor(list(e.getActiveDecisions(6))[0])
1666    'East'
1667    >>> # Now let's pull the lever and go outside, but first, we'll
1668    >>> # return to the Start to demonstrate recordRetrace
1669    >>> # note that recordReturn only applies when the destination of the
1670    >>> # transition is not already known.
1671    >>> obs.recordRetrace('left')  # back to Start
1672    >>> obs.definiteDecisionTarget()
1673    0
1674    >>> obs.recordRetrace('right')  # and back to East
1675    >>> obs.definiteDecisionTarget()
1676    3
1677    >>> obs.exploration.mechanismState('door')
1678    'closed'
1679    >>> obs.recordRetrace('lever', isAction=True)  # door is now open
1680    >>> obs.exploration.mechanismState('door')
1681    'open'
1682    >>> obs.exploration.getExplorationStatus('Outside')
1683    'noticed'
1684    >>> obs.recordExplore('right_door')
1685    >>> obs.definiteDecisionTarget()  # now we're Outside
1686    5
1687    >>> obs.recordReturn('tunnelUnder', 'Start', 'bottom')
1688    >>> obs.definiteDecisionTarget()  # back at the start
1689    0
1690    >>> g = e.getSituation().graph
1691    >>> print(g.namesListing(g).rstrip('\\n'))
1692      0 (Start)
1693      2 (West)
1694      3 (East)
1695      5 (Outside)
1696      6 (Attic)
1697    >>> g.destinationsFrom(0)
1698    {'left': 2, 'right': 3, 'up': 6, 'bottom': 5}
1699    >>> g.destinationsFrom(5)
1700    {'left_door': 3, 'tunnelUnder': 0}
1701
1702    An example of the use of `recordUnify` and `recordObviate`.
1703
1704    >>> obs = JournalObserver()
1705    >>> obs.observe('''
1706    ... S start
1707    ... x right hall left
1708    ... x right room left
1709    ... x vent vents right_vent
1710    ... ''')
1711    >>> obs.recordObviate('middle_vent', 'hall', 'vent')
1712    >>> obs.recordExplore('left_vent', 'new_room', 'vent')
1713    >>> obs.recordUnify('start')
1714    >>> e = obs.getExploration()
1715    >>> len(e)
1716    6
1717    >>> e.getActiveDecisions(0)
1718    set()
1719    >>> [
1720    ...     e.getSituation(n).graph.nameFor(list(e.getActiveDecisions(n))[0])
1721    ...     for n in range(1, 6)
1722    ... ]
1723    ['start', 'hall', 'room', 'vents', 'start']
1724    >>> g = e.getSituation().graph
1725    >>> g.getDestination('start', 'vent')
1726    3
1727    >>> g.getDestination('vents', 'left_vent')
1728    0
1729    >>> g.getReciprocal('start', 'vent')
1730    'left_vent'
1731    >>> g.getReciprocal('vents', 'left_vent')
1732    'vent'
1733    >>> 'new_room' in g
1734    False
1735    """
1736
1737    parseFormat: JournalParseFormat
1738    """
1739    The parse format used to parse entries supplied as text. This also
1740    ends up controlling some of the decision and transition naming
1741    conventions that are followed, so it is not safe to change it
1742    mid-journal; it should be set once before observation begins, and
1743    may be accessed but should not be changed.
1744    """
1745
1746    exploration: core.DiscreteExploration
1747    """
1748    This is the exploration object being built via journal observations.
1749    Note that the exploration object may be empty (i.e., have length 0)
1750    even after the first few entries have been recorded because in some
1751    cases entries are ambiguous and are not translated into exploration
1752    steps until a further entry resolves that ambiguity.
1753    """
1754
1755    preferences: ObservationPreferences
1756    """
1757    Preferences for the observation mechanisms. See
1758    `ObservationPreferences`.
1759    """
1760
1761    uniqueNumber: int
1762    """
1763    A unique number to be substituted (prefixed with '_') into
1764    underscore-substitutions within aliases. Will be incremented for each
1765    such substitution.
1766    """
1767
1768    aliases: Dict[str, Tuple[List[str], str]]
1769    """
1770    The defined aliases for this observer. Each alias has a name, and
1771    stored under that name is a list of parameters followed by a
1772    commands string.
1773    """
1774
1775    def __init__(self, parseFormat: Optional[JournalParseFormat] = None):
1776        """
1777        Sets up the observer. If a parse format is supplied, that will
1778        be used instead of the default parse format, which is just the
1779        result of creating a `ParseFormat` with default arguments.
1780
1781        A simple example:
1782
1783        >>> o = JournalObserver()
1784        >>> o.recordStart('hi')
1785        >>> o.exploration.getExplorationStatus('hi')
1786        'exploring'
1787        >>> e = o.getExploration()
1788        >>> len(e)
1789        2
1790        >>> g = e.getSituation().graph
1791        >>> len(g)
1792        1
1793        >>> e.getActiveContext()
1794        {\
1795'capabilities': {'capabilities': set(), 'tokens': {}, 'skills': {}},\
1796 'focalization': {'main': 'singular'},\
1797 'activeDomains': {'main'},\
1798 'activeDecisions': {'main': 0}\
1799}
1800        >>> list(g.nodes)[0]
1801        0
1802        >>> o.recordObserve('option')
1803        >>> list(g.nodes)
1804        [0, 1]
1805        >>> [g.nameFor(d) for d in g.nodes]
1806        ['hi', '_u.0']
1807        >>> o.recordZone(0, 'Lower')
1808        >>> [g.nameFor(d) for d in g.nodes]
1809        ['hi', '_u.0']
1810        >>> e.getActiveDecisions()
1811        {0}
1812        >>> o.recordZone(1, 'Upper')
1813        >>> o.recordExplore('option', 'bye', 'back')
1814        >>> g = e.getSituation().graph
1815        >>> [g.nameFor(d) for d in g.nodes]
1816        ['hi', 'bye']
1817        >>> o.recordObserve('option2')
1818        >>> import pytest
1819        >>> oldWarn = core.WARN_OF_NAME_COLLISIONS
1820        >>> core.WARN_OF_NAME_COLLISIONS = True
1821        >>> try:
1822        ...     with pytest.warns(core.DecisionCollisionWarning):
1823        ...         o.recordExplore('option2', 'Lower2::hi', 'back')
1824        ... finally:
1825        ...     core.WARN_OF_NAME_COLLISIONS = oldWarn
1826        >>> g = e.getSituation().graph
1827        >>> [g.nameFor(d) for d in g.nodes]
1828        ['hi', 'bye', 'hi']
1829        >>> # Prefix must be specified because it's ambiguous
1830        >>> o.recordWarp('Lower::hi')
1831        >>> g = e.getSituation().graph
1832        >>> [(d, g.nameFor(d)) for d in g.nodes]
1833        [(0, 'hi'), (1, 'bye'), (2, 'hi')]
1834        >>> e.getActiveDecisions()
1835        {0}
1836        >>> o.recordWarp('bye')
1837        >>> g = e.getSituation().graph
1838        >>> [(d, g.nameFor(d)) for d in g.nodes]
1839        [(0, 'hi'), (1, 'bye'), (2, 'hi')]
1840        >>> e.getActiveDecisions()
1841        {1}
1842        """
1843        if parseFormat is None:
1844            self.parseFormat = JournalParseFormat()
1845        else:
1846            self.parseFormat = parseFormat
1847
1848        self.uniqueNumber = 0
1849        self.aliases = {}
1850
1851        # Set up default observation preferences
1852        self.preferences = observationPreferences()
1853
1854        # Create a blank exploration
1855        self.exploration = core.DiscreteExploration()
1856
1857        # Debugging support
1858        self.prevSteps: Optional[int] = None
1859        self.prevDecisions: Optional[int] = None
1860
1861        # Current context tracking focal context, domain, focus point,
1862        # decision, and/or transition that's currently most relevant:
1863        self.context = observationContext()
1864
1865        # TODO: Stack of contexts?
1866        # Stored observation context can be restored as the current
1867        # state later. This is used to support relative mode.
1868        self.storedContext: Optional[
1869            ObservationContext
1870        ] = None
1871
1872        # Whether or not we're in relative mode.
1873        self.inRelativeMode = False
1874
1875        # Tracking which decisions we shouldn't auto-finalize
1876        self.dontFinalize: Set[base.DecisionID] = set()
1877
1878        # Tracking current parse location for errors & warnings
1879        self.journalTexts: List[str] = []  # a stack 'cause of macros
1880        self.parseIndices: List[int] = []  # also a stack
1881
1882    def getExploration(self) -> core.DiscreteExploration:
1883        """
1884        Returns the exploration that this observer edits.
1885        """
1886        return self.exploration
1887
1888    def nextUniqueName(self) -> str:
1889        """
1890        Returns the next unique name for this observer, which is just an
1891        underscore followed by an integer. This increments
1892        `uniqueNumber`.
1893        """
1894        result = '_' + str(self.uniqueNumber)
1895        self.uniqueNumber += 1
1896        return result
1897
1898    def currentDecisionTarget(self) -> Optional[base.DecisionID]:
1899        """
1900        Returns the decision which decision-based changes should be
1901        applied to. Changes depending on whether relative mode is
1902        active. Will be `None` when there is no current position (e.g.,
1903        before the exploration is started).
1904        """
1905        return self.context['decision']
1906
1907    def definiteDecisionTarget(self) -> base.DecisionID:
1908        """
1909        Works like `currentDecisionTarget` but raises a
1910        `core.MissingDecisionError` instead of returning `None` if there
1911        is no current decision.
1912        """
1913        result = self.currentDecisionTarget()
1914
1915        if result is None:
1916            raise core.MissingDecisionError("There is no current decision.")
1917        else:
1918            return result
1919
1920    def decisionTargetSpecifier(self) -> base.DecisionSpecifier:
1921        """
1922        Returns a `base.DecisionSpecifier` which includes domain, zone,
1923        and name for the current decision. The zone used is the first
1924        alphabetical lowest-level zone that the decision is in, which
1925        *could* in some cases remain ambiguous. If you're worried about
1926        that, use `definiteDecisionTarget` instead.
1927
1928        Like `definiteDecisionTarget` this will crash if there isn't a
1929        current decision target.
1930        """
1931        graph = self.exploration.getSituation().graph
1932        dID = self.definiteDecisionTarget()
1933        domain = graph.domainFor(dID)
1934        name = graph.nameFor(dID)
1935        inZones = graph.zoneAncestors(dID)
1936        # Alphabetical order (we have no better option)
1937        ordered = sorted(
1938            inZones,
1939            key=lambda z: (
1940                graph.zoneHierarchyLevel(z),   # level-0 first
1941                z  # alphabetical as tie-breaker
1942            )
1943        )
1944        if len(ordered) > 0:
1945            useZone = ordered[0]
1946        else:
1947            useZone = None
1948
1949        return base.DecisionSpecifier(
1950            domain=domain,
1951            zone=useZone,
1952            name=name
1953        )
1954
1955    def currentTransitionTarget(
1956        self
1957    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
1958        """
1959        Returns the decision, transition pair that identifies the current
1960        transition which transition-based changes should apply to. Will
1961        be `None` when there is no current transition (e.g., just after a
1962        warp).
1963        """
1964        transition = self.context['transition']
1965        if transition is None:
1966            return None
1967        else:
1968            return transition
1969
1970    def currentReciprocalTarget(
1971        self
1972    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
1973        """
1974        Returns the decision, transition pair that identifies the
1975        reciprocal of the `currentTransitionTarget`. Will be `None` when
1976        there is no current transition, or when the current transition
1977        doesn't have a reciprocal (e.g., after an ending).
1978        """
1979        # relative mode is handled by `currentTransitionTarget`
1980        target = self.currentTransitionTarget()
1981        if target is None:
1982            return None
1983        return self.exploration.getSituation().graph.getReciprocalPair(
1984            *target
1985        )
1986
1987    def checkFormat(
1988        self,
1989        entryType: str,
1990        decisionType: base.DecisionType,
1991        target: Union[None, JournalTargetType, Tuple[JournalTargetType, int]],
1992        pieces: List[str],
1993        expectedTargets: Union[
1994            None,
1995            JournalTargetType,
1996            Collection[
1997                Union[None, JournalTargetType]
1998            ]
1999        ],
2000        expectedPieces: Union[None, int, Collection[int]]
2001    ) -> None:
2002        """
2003        Does format checking for a journal entry after
2004        `determineEntryType` is called. Checks that:
2005
2006        - A decision type other than 'active' is only used for entries
2007            where that makes sense.
2008        - The target is one from an allowed list of targets (or is `None`
2009            if `expectedTargets` is set to `None`)
2010        - The number of pieces of content is a specific number or within
2011            a specific collection of allowed numbers. If `expectedPieces`
2012            is set to None, there is no restriction on the number of
2013            pieces.
2014
2015        Raises a `JournalParseError` if its expectations are violated.
2016        """
2017        if decisionType != 'active' and entryType not in (
2018            'START',
2019            'explore',
2020            'retrace',
2021            'return',
2022            'action',
2023            'warp',
2024            'wait',
2025            'END',
2026            'revert'
2027        ):
2028            raise JournalParseError(
2029                f"{entryType} entry may not specify a non-standard"
2030                f" decision type (got {decisionType!r}), because it is"
2031                f" not associated with an exploration action."
2032            )
2033
2034        if expectedTargets is None:
2035            if target is not None:
2036                raise JournalParseError(
2037                    f"{entryType} entry may not specify a target."
2038                )
2039        else:
2040            if isinstance(expectedTargets, str):
2041                expected = cast(
2042                    Collection[Union[None, JournalTargetType]],
2043                    [expectedTargets]
2044                )
2045            else:
2046                expected = cast(
2047                    Collection[Union[None, JournalTargetType]],
2048                    expectedTargets
2049                )
2050            tType = target
2051            if isinstance(tType, tuple):
2052                tType = tType[0]
2053
2054            if tType not in expected:
2055                raise JournalParseError(
2056                    f"{entryType} entry had invalid target {target!r}."
2057                    f" Expected one of:\n{expected}"
2058                )
2059
2060        if expectedPieces is None:
2061            # No restriction
2062            pass
2063        elif isinstance(expectedPieces, int):
2064            if len(pieces) != expectedPieces:
2065                raise JournalParseError(
2066                    f"{entryType} entry had {len(pieces)} arguments but"
2067                    f" only {expectedPieces} argument(s) is/are allowed."
2068                )
2069
2070        elif len(pieces) not in expectedPieces:
2071            allowed = ', '.join(str(x) for x in expectedPieces)
2072            raise JournalParseError(
2073                f"{entryType} entry had {len(pieces)} arguments but the"
2074                f" allowed argument counts are: {allowed}"
2075            )
2076
2077    def parseOneCommand(
2078        self,
2079        journalText: str,
2080        startIndex: int
2081    ) -> Tuple[List[str], int]:
2082        """
2083        Parses a single command from the given journal text, starting at
2084        the specified start index. Each command occupies a single line,
2085        except when blocks are present in which case it may stretch
2086        across multiple lines. This function splits the command up into a
2087        list of strings (including multi-line strings and/or strings
2088        with spaces in them when blocks are used). It returns that list
2089        of strings, along with the index after the newline at the end of
2090        the command it parsed (which could be used as the start index
2091        for the next command). If the command has no newline after it
2092        (only possible when the string ends) the returned index will be
2093        the length of the string.
2094
2095        If the line starting with the start character is empty (or just
2096        contains spaces), the result will be an empty list along with the
2097        index for the start of the next line.
2098
2099        Examples:
2100
2101        >>> o = JournalObserver()
2102        >>> commands = '''\\
2103        ... S start
2104        ... o option
2105        ...
2106        ... x option next back
2107        ... o lever
2108        ...   e edit [
2109        ...     o bridge
2110        ...       q speed
2111        ...   ] [
2112        ...     o bridge
2113        ...       q X
2114        ...   ]
2115        ... a lever
2116        ... '''
2117        >>> o.parseOneCommand(commands, 0)
2118        (['S', 'start'], 8)
2119        >>> o.parseOneCommand(commands, 8)
2120        (['o', 'option'], 17)
2121        >>> o.parseOneCommand(commands, 17)
2122        ([], 18)
2123        >>> o.parseOneCommand(commands, 18)
2124        (['x', 'option', 'next', 'back'], 37)
2125        >>> o.parseOneCommand(commands, 37)
2126        (['o', 'lever'], 45)
2127        >>> bits, end = o.parseOneCommand(commands, 45)
2128        >>> bits[:2]
2129        ['e', 'edit']
2130        >>> bits[2]
2131        'o bridge\\n      q speed'
2132        >>> bits[3]
2133        'o bridge\\n      q X'
2134        >>> len(bits)
2135        4
2136        >>> end
2137        116
2138        >>> o.parseOneCommand(commands, end)
2139        (['a', 'lever'], 124)
2140
2141        >>> o = JournalObserver()
2142        >>> s = "o up Attic down\\nx up\\no vent\\nq crawl"
2143        >>> o.parseOneCommand(s, 0)
2144        (['o', 'up', 'Attic', 'down'], 16)
2145        >>> o.parseOneCommand(s, 16)
2146        (['x', 'up'], 21)
2147        >>> o.parseOneCommand(s, 21)
2148        (['o', 'vent'], 28)
2149        >>> o.parseOneCommand(s, 28)
2150        (['q', 'crawl'], 35)
2151        """
2152
2153        index = startIndex
2154        unit: Optional[str] = None
2155        bits: List[str] = []
2156        pf = self.parseFormat  # shortcut variable
2157        while index < len(journalText):
2158            char = journalText[index]
2159            if char.isspace():
2160                # Space after non-spaces -> end of unit
2161                if unit is not None:
2162                    bits.append(unit)
2163                    unit = None
2164                # End of line -> end of command
2165                if char == '\n':
2166                    index += 1
2167                    break
2168            else:
2169                # Non-space -> check for block
2170                if char == pf.blockStart:
2171                    if unit is not None:
2172                        bits.append(unit)
2173                        unit = None
2174                    blockEnd = pf.findBlockEnd(journalText, index)
2175                    block = journalText[index + 1:blockEnd - 1].strip()
2176                    bits.append(block)
2177                    index = blockEnd  # +1 added below
2178                elif unit is None:  # Initial non-space -> start of unit
2179                    unit = char
2180                else:  # Continuing non-space -> accumulate
2181                    unit += char
2182            # Increment index
2183            index += 1
2184
2185        # Grab final unit if there is one hanging
2186        if unit is not None:
2187            bits.append(unit)
2188
2189        return (bits, index)
2190
2191    def warn(self, message: str) -> None:
2192        """
2193        Issues a `JournalParseWarning`.
2194        """
2195        if len(self.journalTexts) == 0 or len(self.parseIndices) == 0:
2196            warnings.warn(message, JournalParseWarning)
2197        else:
2198            # Note: We use the basal position info because that will
2199            # typically be much more useful when debugging
2200            ec = errorContext(self.journalTexts[0], self.parseIndices[0])
2201            errorCM = textwrap.indent(errorContextMessage(ec), '  ')
2202            warnings.warn(errorCM + '\n' + message, JournalParseWarning)
2203
2204    def observe(self, journalText: str) -> None:
2205        """
2206        Ingests one or more journal blocks in text format (as a
2207        multi-line string) and updates the exploration being built by
2208        this observer, as well as updating internal state.
2209
2210        This method can be called multiple times to process a longer
2211        journal incrementally including line-by-line.
2212
2213        The `journalText` and `parseIndex` fields will be updated during
2214        parsing to support contextual error messages and warnings.
2215
2216        ## Example:
2217
2218        >>> obs = JournalObserver()
2219        >>> oldWarn = core.WARN_OF_NAME_COLLISIONS
2220        >>> try:
2221        ...     obs.observe('''\\
2222        ... S Room1::start
2223        ... zz Region
2224        ... o nope
2225        ...   q power|tokens*3
2226        ... o unexplored
2227        ... o onwards
2228        ... x onwards sub_room backwards
2229        ... t backwards
2230        ... o down
2231        ...
2232        ... x down Room2::middle up
2233        ... a box
2234        ...   At deactivate
2235        ...   At gain tokens*1
2236        ... o left
2237        ... o right
2238        ...   gt blue
2239        ...
2240        ... x right Room3::middle left
2241        ... o right
2242        ... a miniboss
2243        ...   At deactivate
2244        ...   At gain power
2245        ... x right - left
2246        ... o ledge
2247        ...   q tall
2248        ... t left
2249        ... t left
2250        ... t up
2251        ...
2252        ... x nope secret back
2253        ... ''')
2254        ... finally:
2255        ...     core.WARN_OF_NAME_COLLISIONS = oldWarn
2256        >>> e = obs.getExploration()
2257        >>> len(e)
2258        13
2259        >>> g = e.getSituation().graph
2260        >>> len(g)
2261        9
2262        >>> def showDestinations(g, r):
2263        ...     if isinstance(r, str):
2264        ...         r = obs.parseFormat.parseDecisionSpecifier(r)
2265        ...     d = g.destinationsFrom(r)
2266        ...     for outgoing in sorted(d):
2267        ...         req = g.getTransitionRequirement(r, outgoing)
2268        ...         if req is None or req == base.ReqNothing():
2269        ...             req = ''
2270        ...         else:
2271        ...             req = ' ' + repr(req)
2272        ...         print(outgoing, g.identityOf(d[outgoing]) + req)
2273        ...
2274        >>> "start" in g
2275        False
2276        >>> showDestinations(g, "Room1::start")
2277        down 4 (Room2::middle)
2278        nope 1 (Room1::secret) ReqAny([ReqCapability('power'),\
2279 ReqTokens('tokens', 3)])
2280        onwards 3 (Room1::sub_room)
2281        unexplored 2 (_u.1)
2282        >>> showDestinations(g, "Room1::secret")
2283        back 0 (Room1::start)
2284        >>> showDestinations(g, "Room1::sub_room")
2285        backwards 0 (Room1::start)
2286        >>> showDestinations(g, "Room2::middle")
2287        box 4 (Room2::middle)
2288        left 5 (_u.4)
2289        right 6 (Room3::middle)
2290        up 0 (Room1::start)
2291        >>> g.transitionTags(4, "right")
2292        {'blue': 1}
2293        >>> showDestinations(g, "Room3::middle")
2294        left 4 (Room2::middle)
2295        miniboss 6 (Room3::middle)
2296        right 7 (Room3::-)
2297        >>> showDestinations(g, "Room3::-")
2298        ledge 8 (_u.7) ReqCapability('tall')
2299        left 6 (Room3::middle)
2300        >>> showDestinations(g, "_u.7")
2301        return 7 (Room3::-)
2302        >>> e.getActiveDecisions()
2303        {1}
2304        >>> g.identityOf(1)
2305        '1 (Room1::secret)'
2306
2307        Note that there are plenty of other annotations not shown in
2308        this example; see `DEFAULT_FORMAT` for the default mapping from
2309        journal entry types to markers, and see `JournalEntryType` for
2310        the explanation for each entry type.
2311
2312        Most entries start with a marker (which includes one character
2313        for the type and possibly one for the target) followed by a
2314        single space, and everything after that is the content of the
2315        entry.
2316        """
2317        # Normalize newlines
2318        journalText = journalText\
2319            .replace('\r\n', '\n')\
2320            .replace('\n\r', '\n')\
2321            .replace('\r', '\n')
2322
2323        # Shortcut variable
2324        pf = self.parseFormat
2325
2326        # Remove comments from entire text
2327        journalText = pf.removeComments(journalText)
2328
2329        # TODO: Give access to comments in error messages?
2330        # Store for error messages
2331        self.journalTexts.append(journalText)
2332        self.parseIndices.append(0)
2333
2334        startAt = 0
2335        try:
2336            while startAt < len(journalText):
2337                self.parseIndices[-1] = startAt
2338                bits, startAt = self.parseOneCommand(journalText, startAt)
2339
2340                if len(bits) == 0:
2341                    continue
2342
2343                eType, dType, eTarget, eParts = pf.determineEntryType(bits)
2344                if eType == 'preference':
2345                    self.checkFormat(
2346                        'preference',
2347                        dType,
2348                        eTarget,
2349                        eParts,
2350                        None,
2351                        2
2352                    )
2353                    pref = eParts[0]
2354                    opAnn = get_type_hints(ObservationPreferences)
2355                    if pref not in opAnn:
2356                        raise JournalParseError(
2357                            f"Invalid preference name {pref!r}."
2358                        )
2359
2360                    prefVal: Union[None, str, bool, Set[str]]
2361                    if opAnn[pref] is bool:
2362                        prefVal = pf.onOff(eParts[1])
2363                        if prefVal is None:
2364                            self.warn(
2365                                f"On/off value {eParts[1]!r} is neither"
2366                                f" {pf.markerFor('on')!r} nor"
2367                                f" {pf.markerFor('off')!r}. Assuming"
2368                                f" 'off'."
2369                            )
2370                    elif opAnn[pref] == Set[str]:
2371                        prefVal = set(' '.join(eParts[1:]).split())
2372                    else:  # we assume it's a string
2373                        assert opAnn[pref] is str
2374                        prefVal = eParts[1]
2375
2376                    # Set the preference value (type checked above)
2377                    self.preferences[pref] = prefVal  # type: ignore [literal-required] # noqa: E501
2378
2379                elif eType == 'alias':
2380                    self.checkFormat(
2381                        "alias",
2382                        dType,
2383                        eTarget,
2384                        eParts,
2385                        None,
2386                        None
2387                    )
2388
2389                    if len(eParts) < 2:
2390                        raise JournalParseError(
2391                            "Alias entry must include at least an alias"
2392                            " name and a commands list."
2393                        )
2394                    aliasName = eParts[0]
2395                    parameters = eParts[1:-1]
2396                    commands = eParts[-1]
2397                    self.defineAlias(aliasName, parameters, commands)
2398
2399                elif eType == 'custom':
2400                    self.checkFormat(
2401                        "custom",
2402                        dType,
2403                        eTarget,
2404                        eParts,
2405                        None,
2406                        None
2407                    )
2408                    if len(eParts) == 0:
2409                        raise JournalParseError(
2410                            "Custom entry must include at least an alias"
2411                            " name."
2412                        )
2413                    self.deployAlias(eParts[0], eParts[1:])
2414
2415                elif eType == 'DEBUG':
2416                    self.checkFormat(
2417                        "DEBUG",
2418                        dType,
2419                        eTarget,
2420                        eParts,
2421                        None,
2422                        {1, 2}
2423                    )
2424                    if eParts[0] not in get_args(DebugAction):
2425                        raise JournalParseError(
2426                            f"Invalid debug action: {eParts[0]!r}"
2427                        )
2428                    dAction = cast(DebugAction, eParts[0])
2429                    if len(eParts) > 1:
2430                        self.doDebug(dAction, eParts[1])
2431                    else:
2432                        self.doDebug(dAction)
2433
2434                elif eType == 'START':
2435                    self.checkFormat(
2436                        "START",
2437                        dType,
2438                        eTarget,
2439                        eParts,
2440                        None,
2441                        1
2442                    )
2443
2444                    where = pf.parseDecisionSpecifier(eParts[0])
2445                    if isinstance(where, base.DecisionID):
2446                        raise JournalParseError(
2447                            f"Can't use {repr(where)} as a start"
2448                            f" because the start must be a decision"
2449                            f" name, not a decision ID."
2450                        )
2451                    self.recordStart(where, dType)
2452
2453                elif eType == 'explore':
2454                    self.checkFormat(
2455                        "explore",
2456                        dType,
2457                        eTarget,
2458                        eParts,
2459                        None,
2460                        {1, 2, 3}
2461                    )
2462
2463                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2464
2465                    if len(eParts) == 1:
2466                        self.recordExplore(tr, decisionType=dType)
2467                    elif len(eParts) == 2:
2468                        destination = pf.parseDecisionSpecifier(eParts[1])
2469                        self.recordExplore(
2470                            tr,
2471                            destination,
2472                            decisionType=dType
2473                        )
2474                    else:
2475                        destination = pf.parseDecisionSpecifier(eParts[1])
2476                        self.recordExplore(
2477                            tr,
2478                            destination,
2479                            eParts[2],
2480                            decisionType=dType
2481                        )
2482
2483                elif eType == 'return':
2484                    self.checkFormat(
2485                        "return",
2486                        dType,
2487                        eTarget,
2488                        eParts,
2489                        None,
2490                        {1, 2, 3}
2491                    )
2492                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2493                    if len(eParts) > 1:
2494                        destination = pf.parseDecisionSpecifier(eParts[1])
2495                    else:
2496                        destination = None
2497                    if len(eParts) > 2:
2498                        reciprocal = eParts[2]
2499                    else:
2500                        reciprocal = None
2501                    self.recordReturn(
2502                        tr,
2503                        destination,
2504                        reciprocal,
2505                        decisionType=dType
2506                    )
2507
2508                elif eType == 'action':
2509                    self.checkFormat(
2510                        "action",
2511                        dType,
2512                        eTarget,
2513                        eParts,
2514                        None,
2515                        1
2516                    )
2517                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2518                    self.recordAction(tr, decisionType=dType)
2519
2520                elif eType == 'retrace':
2521                    self.checkFormat(
2522                        "retrace",
2523                        dType,
2524                        eTarget,
2525                        eParts,
2526                        (None, 'actionPart'),
2527                        1
2528                    )
2529                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2530                    self.recordRetrace(
2531                        tr,
2532                        decisionType=dType,
2533                        isAction=eTarget == 'actionPart'
2534                    )
2535
2536                elif eType == 'warp':
2537                    self.checkFormat(
2538                        "warp",
2539                        dType,
2540                        eTarget,
2541                        eParts,
2542                        None,
2543                        {1}
2544                    )
2545
2546                    destination = pf.parseDecisionSpecifier(eParts[0])
2547                    self.recordWarp(destination, decisionType=dType)
2548
2549                elif eType == 'wait':
2550                    self.checkFormat(
2551                        "wait",
2552                        dType,
2553                        eTarget,
2554                        eParts,
2555                        None,
2556                        0
2557                    )
2558                    self.recordWait(decisionType=dType)
2559
2560                elif eType == 'observe':
2561                    self.checkFormat(
2562                        "observe",
2563                        dType,
2564                        eTarget,
2565                        eParts,
2566                        (None, 'actionPart', 'endingPart'),
2567                        (1, 2, 3)
2568                    )
2569                    if eTarget is None:
2570                        self.recordObserve(*eParts)
2571                    elif eTarget == 'actionPart':
2572                        if len(eParts) > 1:
2573                            raise JournalParseError(
2574                                f"Observing action {eParts[0]!r} at"
2575                                f" {self.definiteDecisionTarget()!r}:"
2576                                f" neither a destination nor a"
2577                                f" reciprocal may be specified when"
2578                                f" observing an action (did you mean to"
2579                                f" observe a transition?)."
2580                            )
2581                        self.recordObserveAction(*eParts)
2582                    elif eTarget == 'endingPart':
2583                        if len(eParts) > 1:
2584                            raise JournalParseError(
2585                                f"Observing ending {eParts[0]!r} at"
2586                                f" {self.definiteDecisionTarget()!r}:"
2587                                f" neither a destination nor a"
2588                                f" reciprocal may be specified when"
2589                                f" observing an ending (did you mean to"
2590                                f" observe a transition?)."
2591                            )
2592                        self.recordObserveEnding(*eParts)
2593
2594                elif eType == 'END':
2595                    self.checkFormat(
2596                        "END",
2597                        dType,
2598                        eTarget,
2599                        eParts,
2600                        (None, 'actionPart'),
2601                        1
2602                    )
2603                    self.recordEnd(
2604                        eParts[0],
2605                        eTarget == 'actionPart',
2606                        decisionType=dType
2607                    )
2608
2609                elif eType == 'mechanism':
2610                    self.checkFormat(
2611                        "mechanism",
2612                        dType,
2613                        eTarget,
2614                        eParts,
2615                        None,
2616                        1
2617                    )
2618                    mReq = pf.parseRequirement(eParts[0])
2619                    if (
2620                        not isinstance(mReq, base.ReqMechanism)
2621                     or not isinstance(
2622                            mReq.mechanism,
2623                            (base.MechanismName, base.MechanismSpecifier)
2624                        )
2625                    ):
2626                        raise JournalParseError(
2627                            f"Invalid mechanism declaration"
2628                            f" {eParts[0]!r}. Declaration must specify"
2629                            f" mechanism name and starting state."
2630                        )
2631                    mState = mReq.reqState
2632                    if isinstance(mReq.mechanism, base.MechanismName):
2633                        where = self.definiteDecisionTarget()
2634                        mName = mReq.mechanism
2635                    else:
2636                        assert isinstance(
2637                            mReq.mechanism,
2638                            base.MechanismSpecifier
2639                        )
2640                        mSpec = mReq.mechanism
2641                        mName = mSpec.name
2642                        if mSpec.decision is not None:
2643                            where = base.DecisionSpecifier(
2644                                mSpec.domain,
2645                                mSpec.zone,
2646                                mSpec.decision
2647                            )
2648                        else:
2649                            where = self.definiteDecisionTarget()
2650                            graph = self.exploration.getSituation().graph
2651                            thisDomain = graph.domainFor(where)
2652                            theseZones = graph.zoneAncestors(where)
2653                            if (
2654                                mSpec.domain is not None
2655                            and mSpec.domain != thisDomain
2656                            ):
2657                                raise JournalParseError(
2658                                    f"Mechanism specifier {mSpec!r}"
2659                                    f" does not specify a decision but"
2660                                    f" includes domain {mSpec.domain!r}"
2661                                    f" which does not match the domain"
2662                                    f" {thisDomain!r} of the current"
2663                                    f" decision {graph.identityOf(where)}"
2664                                )
2665                            if (
2666                                mSpec.zone is not None
2667                            and mSpec.zone not in theseZones
2668                            ):
2669                                raise JournalParseError(
2670                                    f"Mechanism specifier {mSpec!r}"
2671                                    f" does not specify a decision but"
2672                                    f" includes zone {mSpec.zone!r}"
2673                                    f" which is not one of the zones"
2674                                    f" that the current decision"
2675                                    f" {graph.identityOf(where)} is in:"
2676                                    f"\n{theseZones!r}"
2677                                )
2678                    self.recordMechanism(where, mName, mState)
2679
2680                elif eType == 'requirement':
2681                    self.checkFormat(
2682                        "requirement",
2683                        dType,
2684                        eTarget,
2685                        eParts,
2686                        (None, 'reciprocalPart', 'bothPart'),
2687                        None
2688                    )
2689                    req = pf.parseRequirement(' '.join(eParts))
2690                    if eTarget in (None, 'bothPart'):
2691                        self.recordRequirement(req)
2692                    if eTarget in ('reciprocalPart', 'bothPart'):
2693                        self.recordReciprocalRequirement(req)
2694
2695                elif eType == 'effect':
2696                    self.checkFormat(
2697                        "effect",
2698                        dType,
2699                        eTarget,
2700                        eParts,
2701                        (None, 'reciprocalPart', 'bothPart'),
2702                        None
2703                    )
2704
2705                    consequence: base.Consequence
2706                    try:
2707                        consequence = pf.parseConsequence(' '.join(eParts))
2708                    except parsing.ParseError:
2709                        consequence = [pf.parseEffect(' '.join(eParts))]
2710
2711                    if eTarget in (None, 'bothPart'):
2712                        self.recordTransitionConsequence(consequence)
2713                    if eTarget in ('reciprocalPart', 'bothPart'):
2714                        self.recordReciprocalConsequence(consequence)
2715
2716                elif eType == 'apply':
2717                    self.checkFormat(
2718                        "apply",
2719                        dType,
2720                        eTarget,
2721                        eParts,
2722                        (None, 'transitionPart'),
2723                        None
2724                    )
2725
2726                    toApply: base.Consequence
2727                    try:
2728                        toApply = pf.parseConsequence(' '.join(eParts))
2729                    except parsing.ParseError:
2730                        toApply = [pf.parseEffect(' '.join(eParts))]
2731
2732                    # If we targeted a transition, that means we wanted
2733                    # to both apply the consequence now AND set it up as
2734                    # an consequence of the transition we just took.
2735                    if eTarget == 'transitionPart':
2736                        if self.context['transition'] is None:
2737                            raise JournalParseError(
2738                                "Can't apply a consequence to a"
2739                                " transition here because there is no"
2740                                " current relevant transition."
2741                            )
2742                        # We need to apply these consequences as part of
2743                        # the transition so their trigger count will be
2744                        # tracked properly, but we do not want to
2745                        # re-apply the other parts of the consequence.
2746                        self.recordAdditionalTransitionConsequence(
2747                            toApply
2748                        )
2749                    else:
2750                        # Otherwise just apply the consequence
2751                        self.exploration.applyExtraneousConsequence(
2752                            toApply,
2753                            where=self.context['transition'],
2754                            moveWhich=self.context['focus']
2755                        )
2756                        # Note: no situation-based variables need
2757                        # updating here
2758
2759                elif eType == 'tag':
2760                    self.checkFormat(
2761                        "tag",
2762                        dType,
2763                        eTarget,
2764                        eParts,
2765                        (
2766                            None,
2767                            'decisionPart',
2768                            'transitionPart',
2769                            'reciprocalPart',
2770                            'bothPart',
2771                            'zonePart'
2772                        ),
2773                        None
2774                    )
2775                    tag: base.Tag
2776                    value: base.TagValue
2777                    if len(eParts) == 0:
2778                        raise JournalParseError(
2779                            "tag entry must include at least a tag name."
2780                        )
2781                    elif len(eParts) == 1:
2782                        tag = eParts[0]
2783                        value = 1
2784                    elif len(eParts) == 2:
2785                        tag, value = eParts
2786                        value = pf.parseTagValue(value)
2787                    else:
2788                        raise JournalParseError(
2789                            f"tag entry has too many parts (only a tag"
2790                            f" name and a tag value are allowed). Got:"
2791                            f" {eParts}"
2792                        )
2793
2794                    if eTarget is None:
2795                        self.recordTagStep(tag, value)
2796                    elif eTarget == "decisionPart":
2797                        self.recordTagDecision(tag, value)
2798                    elif eTarget == "transitionPart":
2799                        self.recordTagTranstion(tag, value)
2800                    elif eTarget == "reciprocalPart":
2801                        self.recordTagReciprocal(tag, value)
2802                    elif eTarget == "bothPart":
2803                        self.recordTagTranstion(tag, value)
2804                        self.recordTagReciprocal(tag, value)
2805                    elif eTarget == "zonePart":
2806                        self.recordTagZone(0, tag, value)
2807                    elif (
2808                        isinstance(eTarget, tuple)
2809                    and len(eTarget) == 2
2810                    and eTarget[0] == "zonePart"
2811                    and isinstance(eTarget[1], int)
2812                    ):
2813                        self.recordTagZone(eTarget[1] - 1, tag, value)
2814                    else:
2815                        raise JournalParseError(
2816                            f"Invalid tag target type {eTarget!r}."
2817                        )
2818
2819                elif eType == 'annotate':
2820                    self.checkFormat(
2821                        "annotate",
2822                        dType,
2823                        eTarget,
2824                        eParts,
2825                        (
2826                            None,
2827                            'decisionPart',
2828                            'transitionPart',
2829                            'reciprocalPart',
2830                            'bothPart'
2831                        ),
2832                        None
2833                    )
2834                    if len(eParts) == 0:
2835                        raise JournalParseError(
2836                            "annotation may not be empty."
2837                        )
2838                    combined = ' '.join(eParts)
2839                    if eTarget is None:
2840                        self.recordAnnotateStep(combined)
2841                    elif eTarget == "decisionPart":
2842                        self.recordAnnotateDecision(combined)
2843                    elif eTarget == "transitionPart":
2844                        self.recordAnnotateTranstion(combined)
2845                    elif eTarget == "reciprocalPart":
2846                        self.recordAnnotateReciprocal(combined)
2847                    elif eTarget == "bothPart":
2848                        self.recordAnnotateTranstion(combined)
2849                        self.recordAnnotateReciprocal(combined)
2850                    elif eTarget == "zonePart":
2851                        self.recordAnnotateZone(0, combined)
2852                    elif (
2853                        isinstance(eTarget, tuple)
2854                    and len(eTarget) == 2
2855                    and eTarget[0] == "zonePart"
2856                    and isinstance(eTarget[1], int)
2857                    ):
2858                        self.recordAnnotateZone(eTarget[1] - 1, combined)
2859                    else:
2860                        raise JournalParseError(
2861                            f"Invalid annotation target type {eTarget!r}."
2862                        )
2863
2864                elif eType == 'context':
2865                    self.checkFormat(
2866                        "context",
2867                        dType,
2868                        eTarget,
2869                        eParts,
2870                        None,
2871                        1
2872                    )
2873                    if eParts[0] == pf.markerFor('commonContext'):
2874                        self.recordContextSwap(None)
2875                    else:
2876                        self.recordContextSwap(eParts[0])
2877
2878                elif eType == 'domain':
2879                    self.checkFormat(
2880                        "domain",
2881                        dType,
2882                        eTarget,
2883                        eParts,
2884                        None,
2885                        {1, 2, 3}
2886                    )
2887                    inCommon = False
2888                    if eParts[-1] == pf.markerFor('commonContext'):
2889                        eParts = eParts[:-1]
2890                        inCommon = True
2891                    if len(eParts) == 3:
2892                        raise JournalParseError(
2893                            f"A domain entry may only have 1 or 2"
2894                            f" arguments unless the last argument is"
2895                            f" {repr(pf.markerFor('commonContext'))}"
2896                        )
2897                    elif len(eParts) == 2:
2898                        if eParts[0] == pf.markerFor('exclusiveDomain'):
2899                            self.recordDomainFocus(
2900                                eParts[1],
2901                                exclusive=True,
2902                                inCommon=inCommon
2903                            )
2904                        elif eParts[0] == pf.markerFor('notApplicable'):
2905                            # Deactivate the domain
2906                            self.recordDomainUnfocus(
2907                                eParts[1],
2908                                inCommon=inCommon
2909                            )
2910                        else:
2911                            # Set up new domain w/ given focalization
2912                            focalization = pf.parseFocalization(eParts[1])
2913                            self.recordNewDomain(
2914                                eParts[0],
2915                                focalization,
2916                                inCommon=inCommon
2917                            )
2918                    else:
2919                        # Focus the domain (or possibly create it)
2920                        self.recordDomainFocus(
2921                            eParts[0],
2922                            inCommon=inCommon
2923                        )
2924
2925                elif eType == 'focus':
2926                    self.checkFormat(
2927                        "focus",
2928                        dType,
2929                        eTarget,
2930                        eParts,
2931                        None,
2932                        {1, 2}
2933                    )
2934                    if len(eParts) == 2:  # explicit domain
2935                        self.recordFocusOn(eParts[1], eParts[0])
2936                    else:  # implicit domain
2937                        self.recordFocusOn(eParts[0])
2938
2939                elif eType == 'zone':
2940                    self.checkFormat(
2941                        "zone",
2942                        dType,
2943                        eTarget,
2944                        eParts,
2945                        (None, 'zonePart'),
2946                        1
2947                    )
2948                    if eTarget is None:
2949                        level = 0
2950                    elif eTarget == 'zonePart':
2951                        level = 1
2952                    else:
2953                        assert isinstance(eTarget, tuple)
2954                        assert len(eTarget) == 2
2955                        level = eTarget[1]
2956                    self.recordZone(level, eParts[0])
2957
2958                elif eType == 'unify':
2959                    self.checkFormat(
2960                        "unify",
2961                        dType,
2962                        eTarget,
2963                        eParts,
2964                        (None, 'transitionPart', 'reciprocalPart'),
2965                        (1, 2)
2966                    )
2967                    if eTarget is None:
2968                        decisions = [
2969                            pf.parseDecisionSpecifier(p)
2970                            for p in eParts
2971                        ]
2972                        self.recordUnify(*decisions)
2973                    elif eTarget == 'transitionPart':
2974                        if len(eParts) != 1:
2975                            raise JournalParseError(
2976                                "A transition unification entry may only"
2977                                f" have one argument, but we got"
2978                                f" {len(eParts)}."
2979                            )
2980                        self.recordUnifyTransition(eParts[0])
2981                    elif eTarget == 'reciprocalPart':
2982                        if len(eParts) != 1:
2983                            raise JournalParseError(
2984                                "A transition unification entry may only"
2985                                f" have one argument, but we got"
2986                                f" {len(eParts)}."
2987                            )
2988                        self.recordUnifyReciprocal(eParts[0])
2989                    else:
2990                        raise RuntimeError(
2991                            f"Invalid target type {eTarget} after check"
2992                            f" for unify entry!"
2993                        )
2994
2995                elif eType == 'obviate':
2996                    self.checkFormat(
2997                        "obviate",
2998                        dType,
2999                        eTarget,
3000                        eParts,
3001                        None,
3002                        3
3003                    )
3004                    transition, targetDecision, targetTransition = eParts
3005                    self.recordObviate(
3006                        transition,
3007                        pf.parseDecisionSpecifier(targetDecision),
3008                        targetTransition
3009                    )
3010
3011                elif eType == 'extinguish':
3012                    self.checkFormat(
3013                        "extinguish",
3014                        dType,
3015                        eTarget,
3016                        eParts,
3017                        (
3018                            None,
3019                            'decisionPart',
3020                            'transitionPart',
3021                            'reciprocalPart',
3022                            'bothPart'
3023                        ),
3024                        1
3025                    )
3026                    if eTarget is None:
3027                        eTarget = 'bothPart'
3028                    if eTarget == 'decisionPart':
3029                        self.recordExtinguishDecision(
3030                            pf.parseDecisionSpecifier(eParts[0])
3031                        )
3032                    elif eTarget == 'transitionPart':
3033                        transition = eParts[0]
3034                        here = self.definiteDecisionTarget()
3035                        self.recordExtinguishTransition(
3036                            here,
3037                            transition,
3038                            False
3039                        )
3040                    elif eTarget == 'bothPart':
3041                        transition = eParts[0]
3042                        here = self.definiteDecisionTarget()
3043                        self.recordExtinguishTransition(
3044                            here,
3045                            transition,
3046                            True
3047                        )
3048                    else:  # Must be reciprocalPart
3049                        transition = eParts[0]
3050                        here = self.definiteDecisionTarget()
3051                        now = self.exploration.getSituation()
3052                        rPair = now.graph.getReciprocalPair(here, transition)
3053                        if rPair is None:
3054                            raise JournalParseError(
3055                                f"Attempted to extinguish the"
3056                                f" reciprocal of transition"
3057                                f" {transition!r} which "
3058                                f" has no reciprocal (or which"
3059                                f" doesn't exist from decision"
3060                                f" {now.graph.identityOf(here)})."
3061                            )
3062
3063                        self.recordExtinguishTransition(
3064                            rPair[0],
3065                            rPair[1],
3066                            deleteReciprocal=False
3067                        )
3068
3069                elif eType == 'complicate':
3070                    self.checkFormat(
3071                        "complicate",
3072                        dType,
3073                        eTarget,
3074                        eParts,
3075                        None,
3076                        4
3077                    )
3078                    target, newName, newReciprocal, newRR = eParts
3079                    self.recordComplicate(
3080                        target,
3081                        newName,
3082                        newReciprocal,
3083                        newRR
3084                    )
3085
3086                elif eType == 'status':
3087                    self.checkFormat(
3088                        "status",
3089                        dType,
3090                        eTarget,
3091                        eParts,
3092                        (None, 'unfinishedPart'),
3093                        {0, 1}
3094                    )
3095                    dID = self.definiteDecisionTarget()
3096                    # Default status to use
3097                    status: base.ExplorationStatus = 'explored'
3098                    # Figure out whether a valid status was provided
3099                    if len(eParts) > 0:
3100                        assert len(eParts) == 1
3101                        eArgs = get_args(base.ExplorationStatus)
3102                        if eParts[0] not in eArgs:
3103                            raise JournalParseError(
3104                                f"Invalid explicit exploration status"
3105                                f" {eParts[0]!r}. Exploration statuses"
3106                                f" must be one of:\n{eArgs!r}"
3107                            )
3108                        status = cast(base.ExplorationStatus, eParts[0])
3109                    # Record new status, as long as we have an explicit
3110                    # status OR 'unfinishedPart' was not given. If
3111                    # 'unfinishedPart' was given, also block auto updates
3112                    if eTarget == 'unfinishedPart':
3113                        if len(eParts) > 0:
3114                            self.recordStatus(dID, status)
3115                        self.recordObservationIncomplete(dID)
3116                    else:
3117                        self.recordStatus(dID, status)
3118
3119                elif eType == 'revert':
3120                    self.checkFormat(
3121                        "revert",
3122                        dType,
3123                        eTarget,
3124                        eParts,
3125                        None,
3126                        None
3127                    )
3128                    aspects: List[str]
3129                    if len(eParts) == 0:
3130                        slot = base.DEFAULT_SAVE_SLOT
3131                        aspects = []
3132                    else:
3133                        slot = eParts[0]
3134                        aspects = eParts[1:]
3135                    aspectsSet = set(aspects)
3136                    if len(aspectsSet) == 0:
3137                        aspectsSet = self.preferences['revertAspects']
3138                    self.recordRevert(slot, aspectsSet, decisionType=dType)
3139
3140                elif eType == 'fulfills':
3141                    self.checkFormat(
3142                        "fulfills",
3143                        dType,
3144                        eTarget,
3145                        eParts,
3146                        None,
3147                        2
3148                    )
3149                    condition = pf.parseRequirement(eParts[0])
3150                    fReq = pf.parseRequirement(eParts[1])
3151                    fulfills: Union[
3152                        base.Capability,
3153                        Tuple[base.MechanismID, base.MechanismState]
3154                    ]
3155                    if isinstance(fReq, base.ReqCapability):
3156                        fulfills = fReq.capability
3157                    elif isinstance(fReq, base.ReqMechanism):
3158                        mState = fReq.reqState
3159                        if isinstance(fReq.mechanism, int):
3160                            mID = fReq.mechanism
3161                        else:
3162                            graph = self.exploration.getSituation().graph
3163                            mID = graph.resolveMechanism(
3164                                fReq.mechanism,
3165                                {self.definiteDecisionTarget()}
3166                            )
3167                        fulfills = (mID, mState)
3168                    else:
3169                        raise JournalParseError(
3170                            f"Cannot fulfill {eParts[1]!r} because it"
3171                            f" doesn't specify either a capability or a"
3172                            f" mechanism/state pair."
3173                        )
3174                    self.recordFulfills(condition, fulfills)
3175
3176                elif eType == 'relative':
3177                    self.checkFormat(
3178                        "relative",
3179                        dType,
3180                        eTarget,
3181                        eParts,
3182                        (None, 'transitionPart'),
3183                        (0, 1, 2)
3184                    )
3185                    if (
3186                        len(eParts) == 1
3187                    and eParts[0] == self.parseFormat.markerFor(
3188                            'relative'
3189                        )
3190                    ):
3191                        self.relative()
3192                    elif eTarget == 'transitionPart':
3193                        self.relative(None, *eParts)
3194                    else:
3195                        self.relative(*eParts)
3196
3197                else:
3198                    raise NotImplementedError(
3199                        f"Unrecognized event type {eType!r}."
3200                    )
3201        except Exception as e:
3202            raise LocatedJournalParseError(
3203                journalText,
3204                self.parseIndices[-1],
3205                e
3206            )
3207        finally:
3208            self.journalTexts.pop()
3209            self.parseIndices.pop()
3210
3211    def defineAlias(
3212        self,
3213        name: str,
3214        parameters: Sequence[str],
3215        commands: str
3216    ) -> None:
3217        """
3218        Defines an alias: a block of commands that can be played back
3219        later using the 'custom' command, with parameter substitutions.
3220
3221        If an alias with the specified name already existed, it will be
3222        replaced.
3223
3224        Each of the listed parameters must be supplied when invoking the
3225        alias, and where they appear within curly braces in the commands
3226        string, they will be substituted in. Additional names starting
3227        with '_' plus an optional integer will also be substituted with
3228        unique names (see `nextUniqueName`), with the same name being
3229        used for every instance that shares the same numerical suffix
3230        within each application of the command. Substitution points must
3231        not include spaces; if an open curly brace is followed by
3232        whitesapce or where a close curly brace is proceeded by
3233        whitespace, those will be treated as normal curly braces and will
3234        not create a substitution point.
3235
3236        For example:
3237
3238        >>> o = JournalObserver()
3239        >>> o.defineAlias(
3240        ...     'hintRoom',
3241        ...     ['name'],
3242        ...     'o {_5}\\nx {_5} {name} {_5}\\ngd hint\\nt {_5}'
3243        ... )  # _5 to show that the suffix doesn't matter if it's consistent
3244        >>> o.defineAlias(
3245        ...     'trade',
3246        ...     ['gain', 'lose'],
3247        ...     'A { gain {gain}; lose {lose} }'
3248        ... )  # note outer curly braces
3249        >>> o.recordStart('start')
3250        >>> o.deployAlias('hintRoom', ['hint1'])
3251        >>> o.deployAlias('hintRoom', ['hint2'])
3252        >>> o.deployAlias('trade', ['flower*1', 'coin*1'])
3253        >>> e = o.getExploration()
3254        >>> e.movementAtStep(0)
3255        (None, None, 0)
3256        >>> e.movementAtStep(1)
3257        (0, '_0', 1)
3258        >>> e.movementAtStep(2)
3259        (1, '_0', 0)
3260        >>> e.movementAtStep(3)
3261        (0, '_1', 2)
3262        >>> e.movementAtStep(4)
3263        (2, '_1', 0)
3264        >>> g = e.getSituation().graph
3265        >>> len(g)
3266        3
3267        >>> g.namesListing([0, 1, 2])
3268        '  0 (start)\\n  1 (hint1)\\n  2 (hint2)\\n'
3269        >>> g.decisionTags('hint1')
3270        {'hint': 1}
3271        >>> g.decisionTags('hint2')
3272        {'hint': 1}
3273        >>> e.tokenCountNow('coin')
3274        -1
3275        >>> e.tokenCountNow('flower')
3276        1
3277        """
3278        # Going to be formatted twice so {{{{ -> {{ -> {
3279        # TODO: Move this logic into deployAlias
3280        commands = re.sub(r'{(\s)', r'{{{{\1', commands)
3281        commands = re.sub(r'(\s)}', r'\1}}}}', commands)
3282        self.aliases[name] = (list(parameters), commands)
3283
3284    def deployAlias(self, name: str, arguments: Sequence[str]) -> None:
3285        """
3286        Deploys an alias, taking its command string and substituting in
3287        the provided argument values for each of the alias' parameters,
3288        plus any unique names that it requests. Substitution happens
3289        first for named arguments and then for unique strings, so named
3290        arguments of the form '{_-n-}' where -n- is an integer will end
3291        up being substituted for unique names. Sets of curly braces that
3292        have at least one space immediately after the open brace or
3293        immediately before the closing brace will be interpreted as
3294        normal curly braces, NOT as the start/end of a substitution
3295        point.
3296
3297        There are a few automatic arguments (although these can be
3298        overridden if the alias definition uses the same argument name
3299        explicitly):
3300        - '__here__' will substitute to the ID of the current decision
3301            based on the `ObservationContext`, or will generate an error
3302            if there is none. This is the current decision at the moment
3303            the alias is deployed, NOT based on steps within the alias up
3304            to the substitution point.
3305        - '__hereName__' will substitute the name of the current
3306            decision.
3307        - '__zone__' will substitute the name of the alphabetically
3308            first level-0 zone ancestor of the current decision.
3309        - '__region__' will substitute the name of the alphabetically
3310            first level-1 zone ancestor of the current decision.
3311        - '__transition__' will substitute to the name of the current
3312            transition, or will generate an error if there is none. Note
3313            that the current transition is sometimes NOT a valid
3314            transition from the current decision, because when you take
3315            a transition, that transition's name is current but the
3316            current decision is its destination.
3317        - '__reciprocal__' will substitute to the name of the reciprocal
3318            of the current transition.
3319        - '__trBase__' will substitute to the decision from which the
3320            current transition departs.
3321        - '__trDest__' will substitute to the destination of the current
3322            transition.
3323        - '__prev__' will substitute to the ID of the primary decision in
3324            the previous exploration step, (which is NOT always the
3325            previous current decision of the `ObservationContext`,
3326            especially in relative mode).
3327        - '__across__-name-__' where '-name-' is a transition name will
3328            substitute to the decision reached by traversing that
3329            transition from the '__here__' decision. Note that the
3330            transition name used must be a valid Python identifier.
3331
3332        Raises a `JournalParseError` if the specified alias does not
3333        exist, or if the wrong number of parameters has been supplied.
3334
3335        See `defineAlias` for an example.
3336        """
3337        # Fetch the alias
3338        alias = self.aliases.get(name)
3339        if alias is None:
3340            raise JournalParseError(
3341                f"Alias {name!r} has not been defined yet."
3342            )
3343        paramNames, commands = alias
3344
3345        # Check arguments
3346        arguments = list(arguments)
3347        if len(arguments) != len(paramNames):
3348            raise JournalParseError(
3349                f"Alias {name!r} requires {len(paramNames)} parameters,"
3350                f" but you supplied {len(arguments)}."
3351            )
3352
3353        # Find unique names
3354        uniques = set([
3355            match.strip('{}')
3356            for match in re.findall('{_[0-9]*}', commands)
3357        ])
3358
3359        # Build substitution dictionary that passes through uniques
3360        firstWave = {unique: '{' + unique + '}' for unique in uniques}
3361
3362        # Fill in each non-overridden & requested auto variable:
3363        graph = self.exploration.getSituation().graph
3364        if '{__here__}' in commands and '__here__' not in firstWave:
3365            firstWave['__here__'] = self.definiteDecisionTarget()
3366        if '{__hereName__}' in commands and '__hereName__' not in firstWave:
3367            firstWave['__hereName__'] = graph.nameFor(
3368                self.definiteDecisionTarget()
3369            )
3370        if '{__zone__}' in commands and '__zone__' not in firstWave:
3371            baseDecision = self.definiteDecisionTarget()
3372            parents = sorted(
3373                ancestor
3374                for ancestor in graph.zoneAncestors(baseDecision)
3375                if graph.zoneHierarchyLevel(ancestor) == 0
3376            )
3377            if len(parents) == 0:
3378                raise JournalParseError(
3379                    f"Used __zone__ in a macro, but the current"
3380                    f" decision {graph.identityOf(baseDecision)} is not"
3381                    f" in any level-0 zones."
3382                )
3383            firstWave['__zone__'] = parents[0]
3384        if '{__region__}' in commands and '__region__' not in firstWave:
3385            baseDecision = self.definiteDecisionTarget()
3386            grandparents = sorted(
3387                ancestor
3388                for ancestor in graph.zoneAncestors(baseDecision)
3389                if graph.zoneHierarchyLevel(ancestor) == 1
3390            )
3391            if len(grandparents) == 0:
3392                raise JournalParseError(
3393                    f"Used __region__ in a macro, but the current"
3394                    f" decision {graph.identityOf(baseDecision)} is not"
3395                    f" in any level-1 zones."
3396                )
3397            firstWave['__region__'] = grandparents[0]
3398        if (
3399            '{__transition__}' in commands
3400        and '__transition__' not in firstWave
3401        ):
3402            ctxTr = self.currentTransitionTarget()
3403            if ctxTr is None:
3404                raise JournalParseError(
3405                    f"Can't deploy alias {name!r} because it has a"
3406                    f" __transition__ auto-slot but there is no current"
3407                    f" transition at the current exploration step."
3408                )
3409            firstWave['__transition__'] = ctxTr[1]
3410        if '{__trBase__}' in commands and '__trBase__' not in firstWave:
3411            ctxTr = self.currentTransitionTarget()
3412            if ctxTr is None:
3413                raise JournalParseError(
3414                    f"Can't deploy alias {name!r} because it has a"
3415                    f" __transition__ auto-slot but there is no current"
3416                    f" transition at the current exploration step."
3417                )
3418            firstWave['__trBase__'] = ctxTr[0]
3419        if '{__trDest__}' in commands and '__trDest__' not in firstWave:
3420            ctxTr = self.currentTransitionTarget()
3421            if ctxTr is None:
3422                raise JournalParseError(
3423                    f"Can't deploy alias {name!r} because it has a"
3424                    f" __transition__ auto-slot but there is no current"
3425                    f" transition at the current exploration step."
3426                )
3427            firstWave['__trDest__'] = graph.getDestination(*ctxTr)
3428        if (
3429            '{__reciprocal__}' in commands
3430        and '__reciprocal__' not in firstWave
3431        ):
3432            ctxTr = self.currentTransitionTarget()
3433            if ctxTr is None:
3434                raise JournalParseError(
3435                    f"Can't deploy alias {name!r} because it has a"
3436                    f" __transition__ auto-slot but there is no current"
3437                    f" transition at the current exploration step."
3438                )
3439            firstWave['__reciprocal__'] = graph.getReciprocal(*ctxTr)
3440        if '{__prev__}' in commands and '__prev__' not in firstWave:
3441            try:
3442                prevPrimary = self.exploration.primaryDecision(-2)
3443            except IndexError:
3444                raise JournalParseError(
3445                    f"Can't deploy alias {name!r} because it has a"
3446                    f" __prev__ auto-slot but there is no previous"
3447                    f" exploration step."
3448                )
3449            if prevPrimary is None:
3450                raise JournalParseError(
3451                    f"Can't deploy alias {name!r} because it has a"
3452                    f" __prev__ auto-slot but there is no primary"
3453                    f" decision for the previous exploration step."
3454                )
3455            firstWave['__prev__'] = prevPrimary
3456
3457        here = self.currentDecisionTarget()
3458        for match in re.findall(r'{__across__[^ ]\+__}', commands):
3459            if here is None:
3460                raise JournalParseError(
3461                    f"Can't deploy alias {name!r} because it has an"
3462                    f" __across__ auto-slot but there is no current"
3463                    f" decision."
3464                )
3465            transition = match[11:-3]
3466            dest = graph.getDestination(here, transition)
3467            firstWave[f'__across__{transition}__'] = dest
3468        firstWave.update({
3469            param: value
3470            for (param, value) in zip(paramNames, arguments)
3471        })
3472
3473        # Substitute parameter values
3474        commands = commands.format(**firstWave)
3475
3476        uniques = set([
3477            match.strip('{}')
3478            for match in re.findall('{_[0-9]*}', commands)
3479        ])
3480
3481        # Substitute for remaining unique names
3482        uniqueValues = {
3483            unique: self.nextUniqueName()
3484            for unique in sorted(uniques)  # sort for stability
3485        }
3486        commands = commands.format(**uniqueValues)
3487
3488        # Now run the commands
3489        self.observe(commands)
3490
3491    def doDebug(self, action: DebugAction, arg: str = "") -> None:
3492        """
3493        Prints out a debugging message to stderr. Useful for figuring
3494        out parsing errors. See also `DebugAction` and
3495        `JournalEntryType. Certain actions allow an extra argument. The
3496        action will be one of:
3497        - 'here': prints the ID and name of the current decision, or
3498            `None` if there isn't one.
3499        - 'transition': prints the name of the current transition, or `None`
3500            if there isn't one.
3501        - 'destinations': prints the ID and name of the current decision,
3502            followed by the names of each outgoing transition and their
3503            destinations. Includes any requirements the transitions have.
3504            If an extra argument is supplied, looks up that decision and
3505            prints destinations from there.
3506        - 'steps': prints out the number of steps in the current exploration,
3507            plus the number since the most recent use of 'steps'.
3508        - 'decisions': prints out the number of decisions in the current
3509            graph, plus the number added/removed since the most recent use of
3510            'decisions'.
3511        - 'active': prints out the names listing of all currently active
3512            decisions.
3513        - 'primary': prints out the identity of the current primary
3514            decision, or None if there is none.
3515        - 'saved': prints out the primary decision for the state saved in
3516            the default save slot, or for a specific save slot if a
3517            second argument is given.
3518        - 'inventory': Displays all current capabilities, tokens, and
3519            skills.
3520        - 'mechanisms': Displays all current mechanisms and their states.
3521        - 'equivalences': Displays all current equivalences, along with
3522            whether or not they're active.
3523        """
3524        graph = self.exploration.getSituation().graph
3525        if arg != '' and action not in ('destinations', 'saved'):
3526            raise JournalParseError(
3527                f"Invalid debug command {action!r} with arg {arg!r}:"
3528                f" Only 'destination' and 'saved' actions may include a"
3529                f" second argument."
3530            )
3531        if action == "here":
3532            dt = self.currentDecisionTarget()
3533            print(
3534                f"Current decision is: {graph.identityOf(dt)}",
3535                file=sys.stderr
3536            )
3537        elif action == "transition":
3538            tTarget = self.currentTransitionTarget()
3539            if tTarget is None:
3540                print("Current transition is: None", file=sys.stderr)
3541            else:
3542                tDecision, tTransition = tTarget
3543                print(
3544                    (
3545                        f"Current transition is {tTransition!r} from"
3546                        f" {graph.identityOf(tDecision)}."
3547                    ),
3548                    file=sys.stderr
3549                )
3550        elif action == "destinations":
3551            if arg == "":
3552                here = self.currentDecisionTarget()
3553                adjective = "current"
3554                if here is None:
3555                    print("There is no current decision.", file=sys.stderr)
3556            else:
3557                adjective = "target"
3558                dHint = None
3559                zHint = None
3560                tSpec = self.decisionTargetSpecifier()
3561                if tSpec is not None:
3562                    dHint = tSpec.domain
3563                    zHint = tSpec.zone
3564                here = self.exploration.getSituation().graph.getDecision(
3565                    self.parseFormat.parseDecisionSpecifier(arg),
3566                    zoneHint=zHint,
3567                    domainHint=dHint,
3568                )
3569                if here is None:
3570                    print("Decision {arg!r} was not found.", file=sys.stderr)
3571
3572            if here is not None:
3573                dests = graph.destinationsFrom(here)
3574                outgoing = {
3575                    route: dests[route]
3576                    for route in dests
3577                    if dests[route] != here
3578                }
3579                actions = {
3580                    route: dests[route]
3581                    for route in dests
3582                    if dests[route] == here
3583                }
3584                print(
3585                    f"The {adjective} decision is: {graph.identityOf(here)}",
3586                    file=sys.stderr
3587                )
3588                if len(outgoing) == 0:
3589                    print(
3590                        (
3591                            "There are no outgoing transitions at this"
3592                            " decision."
3593                        ),
3594                        file=sys.stderr
3595                    )
3596                else:
3597                    print(
3598                        (
3599                            f"There are {len(outgoing)} outgoing"
3600                            f" transition(s):"
3601                        ),
3602                        file=sys.stderr
3603                    )
3604                for transition in outgoing:
3605                    destination = outgoing[transition]
3606                    req = graph.getTransitionRequirement(
3607                        here,
3608                        transition
3609                    )
3610                    rstring = ''
3611                    if req != base.ReqNothing():
3612                        rstring = f" (requires {req})"
3613                    print(
3614                        (
3615                            f"  {transition!r} ->"
3616                            f" {graph.identityOf(destination)}{rstring}"
3617                        ),
3618                        file=sys.stderr
3619                    )
3620
3621                if len(actions) > 0:
3622                    print(
3623                        f"There are {len(actions)} actions:",
3624                        file=sys.stderr
3625                    )
3626                    for oneAction in actions:
3627                        req = graph.getTransitionRequirement(
3628                            here,
3629                            oneAction
3630                        )
3631                        rstring = ''
3632                        if req != base.ReqNothing():
3633                            rstring = f" (requires {req})"
3634                        print(
3635                            f"  {oneAction!r}{rstring}",
3636                            file=sys.stderr
3637                        )
3638
3639        elif action == "steps":
3640            steps = len(self.getExploration())
3641            if self.prevSteps is not None:
3642                elapsed = steps - cast(int, self.prevSteps)
3643                print(
3644                    (
3645                        f"There are {steps} steps in the current"
3646                        f" exploration (which is {elapsed} more than"
3647                        f" there were at the previous check)."
3648                    ),
3649                    file=sys.stderr
3650                )
3651            else:
3652                print(
3653                    (
3654                        f"There are {steps} steps in the current"
3655                        f" exploration."
3656                    ),
3657                    file=sys.stderr
3658                )
3659            self.prevSteps = steps
3660
3661        elif action == "decisions":
3662            count = len(self.getExploration().getSituation().graph)
3663            if self.prevDecisions is not None:
3664                elapsed = count - self.prevDecisions
3665                print(
3666                    (
3667                        f"There are {count} decisions in the current"
3668                        f" graph (which is {elapsed} more than there"
3669                        f" were at the previous check)."
3670                    ),
3671                    file=sys.stderr
3672                )
3673            else:
3674                print(
3675                    (
3676                        f"There are {count} decisions in the current"
3677                        f" graph."
3678                    ),
3679                    file=sys.stderr
3680                )
3681            self.prevDecisions = count
3682        elif action == "active":
3683            active = self.exploration.getActiveDecisions()
3684            now = self.exploration.getSituation()
3685            print(
3686                "Active decisions:\n",
3687                now.graph.namesListing(active),
3688                file=sys.stderr
3689            )
3690        elif action == "primary":
3691            e = self.exploration
3692            primary = e.primaryDecision()
3693            if primary is None:
3694                pr = "None"
3695            else:
3696                pr = e.getSituation().graph.identityOf(primary)
3697            print(f"Primary decision: {pr}", file=sys.stderr)
3698        elif action == "saved":
3699            now = self.exploration.getSituation()
3700            slot = base.DEFAULT_SAVE_SLOT
3701            if arg != "":
3702                slot = arg
3703            saved = now.saves.get(slot)
3704            if saved is None:
3705                print(f"Slot {slot!r} has no saved data.", file=sys.stderr)
3706            else:
3707                savedGraph, savedState = saved
3708                savedPrimary = savedGraph.identityOf(
3709                    savedState['primaryDecision']
3710                )
3711                print(f"Saved at decision: {savedPrimary}", file=sys.stderr)
3712        elif action == "inventory":
3713            now = self.exploration.getSituation()
3714            commonCap = now.state['common']['capabilities']
3715            activeCap = now.state['contexts'][now.state['activeContext']][
3716                'capabilities'
3717            ]
3718            merged = base.mergeCapabilitySets(commonCap, activeCap)
3719            capCount = len(merged['capabilities'])
3720            tokCount = len(merged['tokens'])
3721            skillCount = len(merged['skills'])
3722            print(
3723                (
3724                    f"{capCount} capability/ies, {tokCount} token type(s),"
3725                    f" and {skillCount} skill(s)"
3726                ),
3727                file=sys.stderr
3728            )
3729            if capCount > 0:
3730                print("Capabilities (alphabetical order):", file=sys.stderr)
3731                for cap in sorted(merged['capabilities']):
3732                    print(f"  {cap!r}", file=sys.stderr)
3733            if tokCount > 0:
3734                print("Tokens (alphabetical order):", file=sys.stderr)
3735                for tok in sorted(merged['tokens']):
3736                    print(
3737                        f"  {tok!r}: {merged['tokens'][tok]}",
3738                        file=sys.stderr
3739                    )
3740            if skillCount > 0:
3741                print("Skill levels (alphabetical order):", file=sys.stderr)
3742                for skill in sorted(merged['skills']):
3743                    print(
3744                        f"  {skill!r}: {merged['skills'][skill]}",
3745                        file=sys.stderr
3746                    )
3747        elif action == "mechanisms":
3748            now = self.exploration.getSituation()
3749            grpah = now.graph
3750            mechs = now.state['mechanisms']
3751            inGraph = set(graph.mechanisms) - set(mechs)
3752            print(
3753                (
3754                    f"{len(mechs)} mechanism(s) in known states;"
3755                    f" {len(inGraph)} additional mechanism(s) in the"
3756                    f" default state"
3757                ),
3758                file=sys.stderr
3759            )
3760            if len(mechs) > 0:
3761                print("Mechanism(s) in known state(s):", file=sys.stderr)
3762                for mID in sorted(mechs):
3763                    mState = mechs[mID]
3764                    whereID, mName = graph.mechanisms[mID]
3765                    if whereID is None:
3766                        whereStr = " (global)"
3767                    else:
3768                        domain = graph.domainFor(whereID)
3769                        whereStr = f" at {graph.identityOf(whereID)}"
3770                    print(
3771                        f"  {mName}:{mState!r} - {mID}{whereStr}",
3772                        file=sys.stderr
3773                    )
3774            if len(inGraph) > 0:
3775                print("Mechanism(s) in the default state:", file=sys.stderr)
3776                for mID in sorted(inGraph):
3777                    whereID, mName = graph.mechanisms[mID]
3778                    if whereID is None:
3779                        whereStr = " (global)"
3780                    else:
3781                        domain = graph.domainFor(whereID)
3782                        whereStr = f" at {graph.identityOf(whereID)}"
3783                    print(f"  {mID} - {mName}){whereStr}", file=sys.stderr)
3784        elif action == "equivalences":
3785            now = self.exploration.getSituation()
3786            eqDict = now.graph.equivalences
3787            if len(eqDict) > 0:
3788                print(f"{len(eqDict)} equivalences:", file=sys.stderr)
3789                for hasEq in eqDict:
3790                    if isinstance(hasEq, tuple):
3791                        assert len(hasEq) == 2
3792                        assert isinstance(hasEq[0], base.MechanismID)
3793                        assert isinstance(hasEq[1], base.MechanismState)
3794                        mID, mState = hasEq
3795                        mDetails = now.graph.mechanismDetails(mID)
3796                        assert mDetails is not None
3797                        mWhere, mName = mDetails
3798                        if mWhere is None:
3799                            whereStr = " (global)"
3800                        else:
3801                            whereStr = f" at {graph.identityOf(mWhere)}"
3802                        eqStr = f"{mName}:{mState!r} - {mID}{whereStr}"
3803                    else:
3804                        assert isinstance(hasEq, base.Capability)
3805                        eqStr = hasEq
3806                    eqSet = eqDict[hasEq]
3807                    print(
3808                        f"  {eqStr} has {len(eqSet)} equivalence(s):",
3809                        file=sys.stderr
3810                    )
3811                    for eqReq in eqDict[hasEq]:
3812                        print(f"    {eqReq}", file=sys.stderr)
3813            else:
3814                print(
3815                    "There are no equivalences right now.",
3816                    file=sys.stderr
3817                )
3818        else:
3819            raise JournalParseError(
3820                f"Invalid debug command: {action!r}"
3821            )
3822
3823    def recordStart(
3824        self,
3825        where: Union[base.DecisionName, base.DecisionSpecifier],
3826        decisionType: base.DecisionType = 'imposed'
3827    ) -> None:
3828        """
3829        Records the start of the exploration. Use only once in each new
3830        domain, as the very first action in that domain (possibly after
3831        some zone declarations). The contextual domain is used if the
3832        given `base.DecisionSpecifier` doesn't include a domain.
3833
3834        To create new decision points that are disconnected from the rest
3835        of the graph that aren't the first in their domain, use the
3836        `relative` method followed by `recordWarp`.
3837
3838        The default 'imposed' decision type can be overridden for the
3839        action that this generates.
3840        """
3841        if self.inRelativeMode:
3842            raise JournalParseError(
3843                "Can't start the exploration in relative mode."
3844            )
3845
3846        whereSpec: Union[base.DecisionID, base.DecisionSpecifier]
3847        if isinstance(where, base.DecisionName):
3848            whereSpec = self.parseFormat.parseDecisionSpecifier(where)
3849            if isinstance(whereSpec, base.DecisionID):
3850                raise JournalParseError(
3851                    f"Can't use a number for a decision name. Got:"
3852                    f" {where!r}"
3853                )
3854        else:
3855            whereSpec = where
3856
3857        if whereSpec.domain is None:
3858            whereSpec = base.DecisionSpecifier(
3859                domain=self.context['domain'],
3860                zone=whereSpec.zone,
3861                name=whereSpec.name
3862            )
3863        self.context['decision'] = self.exploration.start(
3864            whereSpec,
3865            decisionType=decisionType
3866        )
3867
3868    def recordObserveAction(self, name: base.Transition) -> None:
3869        """
3870        Records the observation of an action at the current decision,
3871        which has the given name.
3872        """
3873        here = self.definiteDecisionTarget()
3874        self.exploration.getSituation().graph.addAction(here, name)
3875        self.context['transition'] = (here, name)
3876
3877    def recordObserve(
3878        self,
3879        name: base.Transition,
3880        destination: Optional[base.AnyDecisionSpecifier] = None,
3881        reciprocal: Optional[base.Transition] = None
3882    ) -> None:
3883        """
3884        Records the observation of a new option at the current decision.
3885
3886        If two or three arguments are given, the destination is still
3887        marked as unexplored, but is given a name (with two arguments)
3888        and the reciprocal transition is named (with three arguments).
3889
3890        When a name or decision specifier is used for the destination,
3891        the domain and/or level-0 zone of the current decision are
3892        filled in if the specifier is a name or doesn't have domain
3893        and/or zone info. The first alphabetical level-0 zone is used if
3894        the current decision is in more than one.
3895        """
3896        here = self.definiteDecisionTarget()
3897
3898        # Our observation matches `DiscreteExploration.observe` args
3899        obs: Union[
3900            Tuple[base.Transition],
3901            Tuple[base.Transition, base.AnyDecisionSpecifier],
3902            Tuple[
3903                base.Transition,
3904                base.AnyDecisionSpecifier,
3905                base.Transition
3906            ]
3907        ]
3908
3909        # If we have a destination, parse it as a decision specifier
3910        # (might be an ID)
3911        if isinstance(destination, str):
3912            destination = self.parseFormat.parseDecisionSpecifier(
3913                destination
3914            )
3915
3916        # If we started with a name or some other kind of decision
3917        # specifier, replace missing domain and/or zone info with info
3918        # from the current decision.
3919        if isinstance(destination, base.DecisionSpecifier):
3920            destination = base.spliceDecisionSpecifiers(
3921                destination,
3922                self.decisionTargetSpecifier()
3923            )
3924            # TODO: This is kinda janky because it only uses 1 zone,
3925            # whereas explore puts the new decision in all of them.
3926
3927        # Set up our observation argument
3928        if destination is not None:
3929            if reciprocal is not None:
3930                obs = (name, destination, reciprocal)
3931            else:
3932                obs = (name, destination)
3933        elif reciprocal is not None:
3934            # TODO: Allow this? (make the destination generic)
3935            raise JournalParseError(
3936                "You may not specify a reciprocal name without"
3937                " specifying a destination."
3938            )
3939        else:
3940            obs = (name,)
3941
3942        self.exploration.observe(here, *obs)
3943        self.context['transition'] = (here, name)
3944
3945    def recordObservationIncomplete(
3946        self,
3947        decision: base.AnyDecisionSpecifier
3948    ):
3949        """
3950        Marks a particular decision as being incompletely-observed.
3951        Normally whenever we leave a decision, we set its exploration
3952        status as 'explored' under the assumption that before moving on
3953        to another decision, we'll note down all of the options at this
3954        one first. Usually, to indicate further exploration
3955        possibilities in a room, you can include a transition, and you
3956        could even use `recordUnify` later to indicate that what seemed
3957        like a junction between two decisions really wasn't, and they
3958        should be merged. But in rare cases, it makes sense instead to
3959        indicate before you leave a decision that you expect to see more
3960        options there later, but you can't or won't observe them now.
3961        Once `recordObservationIncomplete` has been called, the default
3962        mechanism will never upgrade the decision to 'explored', and you
3963        will usually want to eventually call `recordStatus` to
3964        explicitly do that (which also removes it from the
3965        `dontFinalize` set that this method puts it in).
3966
3967        When called on a decision which already has exploration status
3968        'explored', this also sets the exploration status back to
3969        'exploring'.
3970        """
3971        e = self.exploration
3972        dID = e.getSituation().graph.resolveDecision(decision)
3973        if e.getExplorationStatus(dID) == 'explored':
3974            e.setExplorationStatus(dID, 'exploring')
3975        self.dontFinalize.add(dID)
3976
3977    def recordStatus(
3978        self,
3979        decision: base.AnyDecisionSpecifier,
3980        status: base.ExplorationStatus = 'explored'
3981    ):
3982        """
3983        Explicitly records that a particular decision has the specified
3984        exploration status (default 'explored' meaning we think we've
3985        seen everything there). This helps analysts look for unexpected
3986        connections.
3987
3988        Note that normally, exploration statuses will be updated
3989        automatically whenever a decision is first observed (status
3990        'noticed'), first visited (status 'exploring') and first left
3991        behind (status 'explored'). However, using
3992        `recordObservationIncomplete` can prevent the automatic
3993        'explored' update.
3994
3995        This method also removes a decision's `dontFinalize` entry,
3996        although it's probably no longer relevant in any case.
3997        TODO: Still this?
3998
3999        A basic example:
4000
4001        >>> obs = JournalObserver()
4002        >>> e = obs.getExploration()
4003        >>> obs.recordStart('A')
4004        >>> e.getExplorationStatus('A', 0)
4005        'unknown'
4006        >>> e.getExplorationStatus('A', 1)
4007        'exploring'
4008        >>> obs.recordStatus('A')
4009        >>> e.getExplorationStatus('A', 1)
4010        'explored'
4011        >>> obs.recordStatus('A', 'hypothesized')
4012        >>> e.getExplorationStatus('A', 1)
4013        'hypothesized'
4014
4015        An example of usage in journal format:
4016
4017        >>> obs = JournalObserver()
4018        >>> obs.observe('''
4019        ... # step 0
4020        ... S A  # step 1
4021        ... x right B left  # step 2
4022        ...   ...
4023        ... x right C left  # step 3
4024        ... t left  # back to B; step 4
4025        ...   o up
4026        ...   .  # now we think we've found all options
4027        ... x up D down  # step 5
4028        ... t down  # back to B again; step 6
4029        ... x down E up  # surprise extra option; step 7
4030        ... w  # step 8
4031        ...   . hypothesized  # explicit value
4032        ... t up  # auto-updates to 'explored'; step 9
4033        ... ''')
4034        >>> e = obs.getExploration()
4035        >>> len(e)
4036        10
4037        >>> e.getExplorationStatus('A', 1)
4038        'exploring'
4039        >>> e.getExplorationStatus('A', 2)
4040        'explored'
4041        >>> e.getExplorationStatus('B', 1)
4042        Traceback (most recent call last):
4043        ...
4044        exploration.core.MissingDecisionError...
4045        >>> e.getExplorationStatus(1, 1)  # the unknown node is created
4046        'unknown'
4047        >>> e.getExplorationStatus('B', 2)
4048        'exploring'
4049        >>> e.getExplorationStatus('B', 3)  # not 'explored' yet
4050        'exploring'
4051        >>> e.getExplorationStatus('B', 4)  # now explored
4052        'explored'
4053        >>> e.getExplorationStatus('B', 6)  # still explored
4054        'explored'
4055        >>> e.getExplorationStatus('E', 7)  # initial
4056        'exploring'
4057        >>> e.getExplorationStatus('E', 8)  # explicit
4058        'hypothesized'
4059        >>> e.getExplorationStatus('E', 9)  # auto-update on leave
4060        'explored'
4061        >>> g2 = e.getSituation(2).graph
4062        >>> g4 = e.getSituation(4).graph
4063        >>> g7 = e.getSituation(7).graph
4064        >>> g2.destinationsFrom('B')
4065        {'left': 0, 'right': 2}
4066        >>> g4.destinationsFrom('B')
4067        {'left': 0, 'right': 2, 'up': 3}
4068        >>> g7.destinationsFrom('B')
4069        {'left': 0, 'right': 2, 'up': 3, 'down': 4}
4070        """
4071        e = self.exploration
4072        dID = e.getSituation().graph.resolveDecision(decision)
4073        if dID in self.dontFinalize:
4074            self.dontFinalize.remove(dID)
4075        e.setExplorationStatus(decision, status)
4076
4077    def autoFinalizeExplorationStatuses(self):
4078        """
4079        Looks at the set of nodes that were active in the previous
4080        exploration step but which are no longer active in this one, and
4081        sets their exploration statuses to 'explored' to indicate that
4082        we believe we've already at least observed all of their outgoing
4083        transitions.
4084
4085        Skips finalization for any decisions in our `dontFinalize` set
4086        (see `recordObservationIncomplete`).
4087        """
4088        oldActive = self.exploration.getActiveDecisions(-2)
4089        newAcive = self.exploration.getActiveDecisions()
4090        for leftBehind in (oldActive - newAcive) - self.dontFinalize:
4091            self.exploration.setExplorationStatus(
4092                leftBehind,
4093                'explored'
4094            )
4095
4096    def recordExplore(
4097        self,
4098        transition: base.AnyTransition,
4099        destination: Optional[base.AnyDecisionSpecifier] = None,
4100        reciprocal: Optional[base.Transition] = None,
4101        decisionType: base.DecisionType = 'active'
4102    ) -> None:
4103        """
4104        Records the exploration of a transition which leads to a
4105        specific destination (possibly with outcomes specified for
4106        challenges that are part of that transition's consequences). The
4107        name of the reciprocal transition may also be specified, as can
4108        a non-default decision type (see `base.DecisionType`). Creates
4109        the transition if it needs to.
4110
4111        Note that if the destination specifier has no zone or domain
4112        information, even if a decision with that name already exists, if
4113        the current decision is in a level-0 zone and the existing
4114        decision is not in the same zone, a new decision with that name
4115        in the current level-0 zone will be created (otherwise, it would
4116        be an error to use 'explore' to connect to an already-visited
4117        decision).
4118
4119        If no destination name is specified, the destination node must
4120        already exist and the name of the destination must not begin
4121        with '_u.' otherwise a `JournalParseError` will be generated.
4122
4123        Sets the current transition to the transition taken.
4124
4125        Calls `autoFinalizeExplorationStatuses` to upgrade exploration
4126        statuses for no-longer-active nodes to 'explored'.
4127
4128        In relative mode, this makes all the same changes to the graph,
4129        without adding a new exploration step, applying transition
4130        effects, or changing exploration statuses.
4131        """
4132        here = self.definiteDecisionTarget()
4133
4134        transitionName, outcomes = base.nameAndOutcomes(transition)
4135
4136        # Create transition if it doesn't already exist
4137        now = self.exploration.getSituation()
4138        graph = now.graph
4139        leadsTo = graph.getDestination(here, transitionName)
4140
4141        if isinstance(destination, str):
4142            destination = self.parseFormat.parseDecisionSpecifier(
4143                destination
4144            )
4145
4146        newDomain: Optional[base.Domain]
4147        newZone: Union[
4148            base.Zone,
4149            type[base.DefaultZone],
4150            None
4151        ] = base.DefaultZone
4152        newName: Optional[base.DecisionName]
4153
4154        # if a destination is specified, we need to check that it's not
4155        # an already-existing decision
4156        connectBack: bool = False  # are we connecting to a known decision?
4157        if destination is not None:
4158            # If it's not an ID, splice in current node info:
4159            if isinstance(destination, base.DecisionName):
4160                destination = base.DecisionSpecifier(None, None, destination)
4161            if isinstance(destination, base.DecisionSpecifier):
4162                destination = base.spliceDecisionSpecifiers(
4163                    destination,
4164                    self.decisionTargetSpecifier()
4165                )
4166            exists = graph.getDecision(destination)
4167            # if the specified decision doesn't exist; great. We'll
4168            # create it below
4169            if exists is not None:
4170                # If it does exist, we may have a problem. 'return' must
4171                # be used instead of 'explore' to connect to an existing
4172                # visited decision. But let's see if we really have a
4173                # conflict?
4174                otherZones = set(
4175                    z
4176                    for z in graph.zoneParents(exists)
4177                    if graph.zoneHierarchyLevel(z) == 0
4178                )
4179                currentZones = set(
4180                    z
4181                    for z in graph.zoneParents(here)
4182                    if graph.zoneHierarchyLevel(z) == 0
4183                )
4184                if (
4185                    len(otherZones & currentZones) != 0
4186                 or (
4187                        len(otherZones) == 0
4188                    and len(currentZones) == 0
4189                    )
4190                ):
4191                    if self.exploration.hasBeenVisited(exists):
4192                        # A decision by this name exists and shares at
4193                        # least one level-0 zone with the current
4194                        # decision. That means that 'return' should have
4195                        # been used.
4196                        raise JournalParseError(
4197                            f"Destiation {destination} is invalid"
4198                            f" because that decision has already been"
4199                            f" visited in the current zone. Use"
4200                            f" 'return' to record a new connection to"
4201                            f" an already-visisted decision."
4202                        )
4203                    else:
4204                        connectBack = True
4205                else:
4206                    connectBack = True
4207                # Otherwise, we can continue; the DefaultZone setting
4208                # already in place will prevail below
4209
4210        # Figure out domain & zone info for new destination
4211        if isinstance(destination, base.DecisionSpecifier):
4212            # Use current decision's domain by default
4213            if destination.domain is not None:
4214                newDomain = destination.domain
4215            else:
4216                newDomain = graph.domainFor(here)
4217
4218            # Use specified zone if there is one, else leave it as
4219            # DefaultZone to inherit zone(s) from the current decision.
4220            if destination.zone is not None:
4221                newZone = destination.zone
4222
4223            newName = destination.name
4224            # TODO: Some way to specify non-zone placement in explore?
4225
4226        elif isinstance(destination, base.DecisionID):
4227            if connectBack:
4228                newDomain = graph.domainFor(here)
4229                newZone = None
4230                newName = None
4231            else:
4232                raise JournalParseError(
4233                    f"You cannot use a decision ID when specifying a"
4234                    f" new name for an exploration destination (got:"
4235                    f" {repr(destination)})"
4236                )
4237
4238        elif isinstance(destination, base.DecisionName):
4239            newDomain = None
4240            newZone = base.DefaultZone
4241            newName = destination
4242
4243        else:  # must be None
4244            assert destination is None
4245            newDomain = None
4246            newZone = base.DefaultZone
4247            newName = None
4248
4249        if leadsTo is None:
4250            if newName is None and not connectBack:
4251                raise JournalParseError(
4252                    f"Transition {transition!r} at decision"
4253                    f" {graph.identityOf(here)} does not already exist,"
4254                    f" so a destination name must be provided."
4255                )
4256            else:
4257                graph.addUnexploredEdge(
4258                    here,
4259                    transitionName,
4260                    toDomain=newDomain  # None is the default anyways
4261                )
4262                # Zone info only added in next step
4263        elif newName is None:
4264            # TODO: Generalize this... ?
4265            currentName = graph.nameFor(leadsTo)
4266            if currentName.startswith('_u.'):
4267                raise JournalParseError(
4268                    f"Destination {graph.identityOf(leadsTo)} from"
4269                    f" decision {graph.identityOf(here)} via transition"
4270                    f" {transition!r} must be named when explored,"
4271                    f" because its current name is a placeholder."
4272                )
4273            else:
4274                newName = currentName
4275
4276        # TODO: Check for incompatible domain/zone in destination
4277        # specifier?
4278
4279        if self.inRelativeMode:
4280            if connectBack:  # connect to existing unconfirmed decision
4281                assert exists is not None
4282                graph.replaceUnconfirmed(
4283                    here,
4284                    transitionName,
4285                    exists,
4286                    reciprocal
4287                )  # we assume zones are already in place here
4288                self.exploration.setExplorationStatus(
4289                    exists,
4290                    'noticed',
4291                    upgradeOnly=True
4292                )
4293            else:  # connect to a new decision
4294                graph.replaceUnconfirmed(
4295                    here,
4296                    transitionName,
4297                    newName,
4298                    reciprocal,
4299                    placeInZone=newZone,
4300                    forceNew=True
4301                )
4302                destID = graph.destination(here, transitionName)
4303                self.exploration.setExplorationStatus(
4304                    destID,
4305                    'noticed',
4306                    upgradeOnly=True
4307                )
4308            self.context['decision'] = graph.destination(
4309                here,
4310                transitionName
4311            )
4312            self.context['transition'] = (here, transitionName)
4313        else:
4314            if connectBack:  # to a known but unvisited decision
4315                destID = self.exploration.explore(
4316                    (transitionName, outcomes),
4317                    exists,
4318                    reciprocal,
4319                    zone=newZone,
4320                    decisionType=decisionType
4321                )
4322            else:  # to an entirely new decision
4323                destID = self.exploration.explore(
4324                    (transitionName, outcomes),
4325                    newName,
4326                    reciprocal,
4327                    zone=newZone,
4328                    decisionType=decisionType
4329                )
4330            self.context['decision'] = destID
4331            self.context['transition'] = (here, transitionName)
4332            self.autoFinalizeExplorationStatuses()
4333
4334    def recordRetrace(
4335        self,
4336        transition: base.AnyTransition,
4337        decisionType: base.DecisionType = 'active',
4338        isAction: Optional[bool] = None
4339    ) -> None:
4340        """
4341        Records retracing a transition which leads to a known
4342        destination. A non-default decision type can be specified. If
4343        `isAction` is True or False, the transition must be (or must not
4344        be) an action (i.e., a transition whose destination is the same
4345        as its source). If `isAction` is left as `None` (the default)
4346        then either normal or action transitions can be retraced.
4347
4348        Sets the current transition to the transition taken.
4349
4350        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4351
4352        In relative mode, simply sets the current transition target to
4353        the transition taken and sets the current decision target to its
4354        destination (it does not apply transition effects).
4355        """
4356        here = self.definiteDecisionTarget()
4357
4358        transitionName, outcomes = base.nameAndOutcomes(transition)
4359
4360        graph = self.exploration.getSituation().graph
4361        destination = graph.getDestination(here, transitionName)
4362        if destination is None:
4363            valid = graph.destinationsListing(graph.destinationsFrom(here))
4364            raise JournalParseError(
4365                f"Cannot retrace transition {transitionName!r} from"
4366                f" decision {graph.identityOf(here)}: that transition"
4367                f" does not exist. Destinations available are:"
4368                f"\n{valid}"
4369            )
4370        if isAction is True and destination != here:
4371            raise JournalParseError(
4372                f"Cannot retrace transition {transitionName!r} from"
4373                f" decision {graph.identityOf(here)}: that transition"
4374                f" leads to {graph.identityOf(destination)} but you"
4375                f" specified that an existing action should be retraced,"
4376                f" not a normal transition. Use `recordAction` instead"
4377                f" to record a new action (including converting an"
4378                f" unconfirmed transition into an action). Leave"
4379                f" `isAction` unspeicfied or set it to `False` to"
4380                f" retrace a normal transition."
4381            )
4382        elif isAction is False and destination == here:
4383            raise JournalParseError(
4384                f"Cannot retrace transition {transitionName!r} from"
4385                f" decision {graph.identityOf(here)}: that transition"
4386                f" leads back to {graph.identityOf(destination)} but you"
4387                f" specified that an outgoing transition should be"
4388                f" retraced, not an action. Use `recordAction` instead"
4389                f" to record a new action (which must not have the same"
4390                f" name as any outgoing transition). Leave `isAction`"
4391                f" unspeicfied or set it to `True` to retrace an action."
4392            )
4393
4394        if not self.inRelativeMode:
4395            destID = self.exploration.retrace(
4396                (transitionName, outcomes),
4397                decisionType=decisionType
4398            )
4399            self.autoFinalizeExplorationStatuses()
4400        self.context['decision'] = destID
4401        self.context['transition'] = (here, transitionName)
4402
4403    def recordAction(
4404        self,
4405        action: base.AnyTransition,
4406        decisionType: base.DecisionType = 'active'
4407    ) -> None:
4408        """
4409        Records a new action taken at the current decision. A
4410        non-standard decision type may be specified. If a transition of
4411        that name already existed, it will be converted into an action
4412        assuming that its destination is unexplored and has no
4413        connections yet, and that its reciprocal also has no special
4414        properties yet. If those assumptions do not hold, a
4415        `JournalParseError` will be raised under the assumption that the
4416        name collision was an accident, not intentional, since the
4417        destination and reciprocal are deleted in the process of
4418        converting a normal transition into an action.
4419
4420        This cannot be used to re-triggger an existing action, use
4421        'retrace' for that.
4422
4423        In relative mode, the action is created (or the transition is
4424        converted into an action) but effects are not applied.
4425
4426        Although this does not usually change which decisions are
4427        active, it still calls `autoFinalizeExplorationStatuses` unless
4428        in relative mode.
4429
4430        Example:
4431
4432        >>> o = JournalObserver()
4433        >>> e = o.getExploration()
4434        >>> o.recordStart('start')
4435        >>> o.recordObserve('transition')
4436        >>> e.effectiveCapabilities()['capabilities']
4437        set()
4438        >>> o.recordObserveAction('action')
4439        >>> o.recordTransitionConsequence([base.effect(gain="capability")])
4440        >>> o.recordRetrace('action', isAction=True)
4441        >>> e.effectiveCapabilities()['capabilities']
4442        {'capability'}
4443        >>> o.recordAction('another') # add effects after...
4444        >>> effect = base.effect(lose="capability")
4445        >>> # This applies the effect and then adds it to the
4446        >>> # transition, since we already took the transition
4447        >>> o.recordAdditionalTransitionConsequence([effect])
4448        >>> e.effectiveCapabilities()['capabilities']
4449        set()
4450        >>> len(e)
4451        4
4452        >>> e.getActiveDecisions(0)
4453        set()
4454        >>> e.getActiveDecisions(1)
4455        {0}
4456        >>> e.getActiveDecisions(2)
4457        {0}
4458        >>> e.getActiveDecisions(3)
4459        {0}
4460        >>> e.getSituation(0).action
4461        ('start', 0, 0, 'main', None, None, None)
4462        >>> e.getSituation(1).action
4463        ('take', 'active', 0, ('action', []))
4464        >>> e.getSituation(2).action
4465        ('take', 'active', 0, ('another', []))
4466        """
4467        here = self.definiteDecisionTarget()
4468
4469        actionName, outcomes = base.nameAndOutcomes(action)
4470
4471        # Check if the transition already exists
4472        now = self.exploration.getSituation()
4473        graph = now.graph
4474        hereIdent = graph.identityOf(here)
4475        destinations = graph.destinationsFrom(here)
4476
4477        # A transition going somewhere else
4478        if actionName in destinations:
4479            if destinations[actionName] == here:
4480                raise JournalParseError(
4481                    f"Action {actionName!r} already exists as an action"
4482                    f" at decision {hereIdent!r}. Use 'retrace' to"
4483                    " re-activate an existing action."
4484                )
4485            else:
4486                destination = destinations[actionName]
4487                reciprocal = graph.getReciprocal(here, actionName)
4488                # To replace a transition with an action, the transition
4489                # may only have outgoing properties. Otherwise we assume
4490                # it's an error to name the action after a transition
4491                # which was intended to be a real transition.
4492                if (
4493                    graph.isConfirmed(destination)
4494                 or self.exploration.hasBeenVisited(destination)
4495                 or cast(int, graph.degree(destination)) > 2
4496                    # TODO: Fix MultiDigraph type stubs...
4497                ):
4498                    raise JournalParseError(
4499                        f"Action {actionName!r} has the same name as"
4500                        f" outgoing transition {actionName!r} at"
4501                        f" decision {hereIdent!r}. We cannot turn that"
4502                        f" transition into an action since its"
4503                        f" destination is already explored or has been"
4504                        f" connected to."
4505                    )
4506                if (
4507                    reciprocal is not None
4508                and graph.getTransitionProperties(
4509                        destination,
4510                        reciprocal
4511                    ) != {
4512                        'requirement': base.ReqNothing(),
4513                        'effects': [],
4514                        'tags': {},
4515                        'annotations': []
4516                    }
4517                ):
4518                    raise JournalParseError(
4519                        f"Action {actionName!r} has the same name as"
4520                        f" outgoing transition {actionName!r} at"
4521                        f" decision {hereIdent!r}. We cannot turn that"
4522                        f" transition into an action since its"
4523                        f" reciprocal has custom properties."
4524                    )
4525
4526                if (
4527                    graph.decisionAnnotations(destination) != []
4528                 or graph.decisionTags(destination) != {'unknown': 1}
4529                ):
4530                    raise JournalParseError(
4531                        f"Action {actionName!r} has the same name as"
4532                        f" outgoing transition {actionName!r} at"
4533                        f" decision {hereIdent!r}. We cannot turn that"
4534                        f" transition into an action since its"
4535                        f" destination has tags and/or annotations."
4536                    )
4537
4538                # If we get here, re-target the transition, and then
4539                # destroy the old destination along with the old
4540                # reciprocal edge.
4541                graph.retargetTransition(
4542                    here,
4543                    actionName,
4544                    here,
4545                    swapReciprocal=False
4546                )
4547                graph.removeDecision(destination)
4548
4549        # This will either take the existing action OR create it if
4550        # necessary
4551        if self.inRelativeMode:
4552            if actionName not in destinations:
4553                graph.addAction(here, actionName)
4554        else:
4555            destID = self.exploration.takeAction(
4556                (actionName, outcomes),
4557                fromDecision=here,
4558                decisionType=decisionType
4559            )
4560            self.autoFinalizeExplorationStatuses()
4561            self.context['decision'] = destID
4562        self.context['transition'] = (here, actionName)
4563
4564    def recordReturn(
4565        self,
4566        transition: base.AnyTransition,
4567        destination: Optional[base.AnyDecisionSpecifier] = None,
4568        reciprocal: Optional[base.Transition] = None,
4569        decisionType: base.DecisionType = 'active'
4570    ) -> None:
4571        """
4572        Records an exploration which leads back to a
4573        previously-encountered decision. If a reciprocal is specified,
4574        we connect to that transition as our reciprocal (it must have
4575        led to an unknown area or not have existed) or if not, we make a
4576        new connection with an automatic reciprocal name.
4577        A non-standard decision type may be specified.
4578
4579        If no destination is specified, then the destination of the
4580        transition must already exist.
4581
4582        If the specified transition does not exist, it will be created.
4583
4584        Sets the current transition to the transition taken.
4585
4586        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4587
4588        In relative mode, does the same stuff but doesn't apply any
4589        transition effects.
4590        """
4591        here = self.definiteDecisionTarget()
4592        now = self.exploration.getSituation()
4593        graph = now.graph
4594
4595        transitionName, outcomes = base.nameAndOutcomes(transition)
4596
4597        if destination is None:
4598            destination = graph.getDestination(here, transitionName)
4599            if destination is None:
4600                raise JournalParseError(
4601                    f"Cannot 'return' across transition"
4602                    f" {transitionName!r} from decision"
4603                    f" {graph.identityOf(here)} without specifying a"
4604                    f" destination, because that transition does not"
4605                    f" already have a destination."
4606                )
4607
4608        if isinstance(destination, str):
4609            destination = self.parseFormat.parseDecisionSpecifier(
4610                destination
4611            )
4612
4613        # If we started with a name or some other kind of decision
4614        # specifier, replace missing domain and/or zone info with info
4615        # from the current decision.
4616        if isinstance(destination, base.DecisionSpecifier):
4617            destination = base.spliceDecisionSpecifiers(
4618                destination,
4619                self.decisionTargetSpecifier()
4620            )
4621
4622        # Add an unexplored edge just before doing the return if the
4623        # named transition didn't already exist.
4624        if graph.getDestination(here, transitionName) is None:
4625            graph.addUnexploredEdge(here, transitionName)
4626
4627        # Works differently in relative mode
4628        if self.inRelativeMode:
4629            graph.replaceUnconfirmed(
4630                here,
4631                transitionName,
4632                destination,
4633                reciprocal
4634            )
4635            self.context['decision'] = graph.resolveDecision(destination)
4636            self.context['transition'] = (here, transitionName)
4637        else:
4638            destID = self.exploration.returnTo(
4639                (transitionName, outcomes),
4640                destination,
4641                reciprocal,
4642                decisionType=decisionType
4643            )
4644            self.autoFinalizeExplorationStatuses()
4645            self.context['decision'] = destID
4646            self.context['transition'] = (here, transitionName)
4647
4648    def recordWarp(
4649        self,
4650        destination: base.AnyDecisionSpecifier,
4651        decisionType: base.DecisionType = 'active'
4652    ) -> None:
4653        """
4654        Records a warp to a specific destination without creating a
4655        transition. If the destination did not exist, it will be
4656        created (but only if a `base.DecisionName` or
4657        `base.DecisionSpecifier` was supplied; a destination cannot be
4658        created based on a non-existent `base.DecisionID`).
4659        A non-standard decision type may be specified.
4660
4661        If the destination already exists its zones won't be changed.
4662        However, if the destination gets created, it will be in the same
4663        domain and added to the same zones as the previous position, or
4664        to whichever zone was specified as the zone component of a
4665        `base.DecisionSpecifier`, if any.
4666
4667        Sets the current transition to `None`.
4668
4669        In relative mode, simply updates the current target decision and
4670        sets the current target transition to `None`. It will still
4671        create the destination if necessary, possibly putting it in a
4672        zone. In relative mode, the destination's exploration status is
4673        set to "noticed" (and no exploration step is created), while in
4674        normal mode, the exploration status is set to 'unknown' in the
4675        original current step, and then a new step is added which will
4676        set the status to 'exploring'.
4677
4678        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4679        """
4680        now = self.exploration.getSituation()
4681        graph = now.graph
4682
4683        if isinstance(destination, str):
4684            destination = self.parseFormat.parseDecisionSpecifier(
4685                destination
4686            )
4687
4688        destID = graph.getDecision(destination)
4689
4690        newZone: Union[
4691            base.Zone,
4692            type[base.DefaultZone],
4693            None
4694        ] = base.DefaultZone
4695        here = self.currentDecisionTarget()
4696        newDomain: Optional[base.Domain] = None
4697        if here is not None:
4698            newDomain = graph.domainFor(here)
4699        if self.inRelativeMode:  # create the decision if it didn't exist
4700            if destID not in graph:  # including if it's None
4701                if isinstance(destination, base.DecisionID):
4702                    raise JournalParseError(
4703                        f"Cannot go to decision {destination} because that"
4704                        f" decision ID does not exist, and we cannot create"
4705                        f" a new decision based only on a decision ID. Use"
4706                        f" a DecisionSpecifier or DecisionName to go to a"
4707                        f" new decision that needs to be created."
4708                    )
4709                elif isinstance(destination, base.DecisionName):
4710                    newName = destination
4711                    newZone = base.DefaultZone
4712                elif isinstance(destination, base.DecisionSpecifier):
4713                    specDomain, newZone, newName = destination
4714                    if specDomain is not None:
4715                        newDomain = specDomain
4716                else:
4717                    raise JournalParseError(
4718                        f"Invalid decision specifier: {repr(destination)}."
4719                        f" The destination must be a decision ID, a"
4720                        f" decision name, or a decision specifier."
4721                    )
4722                destID = graph.addDecision(newName, domain=newDomain)
4723                if newZone is base.DefaultZone:
4724                    ctxDecision = self.context['decision']
4725                    if ctxDecision is not None:
4726                        for zp in graph.zoneParents(ctxDecision):
4727                            graph.addDecisionToZone(destID, zp)
4728                elif newZone is not None:
4729                    graph.addDecisionToZone(destID, newZone)
4730                    # TODO: If this zone is new create it & add it to
4731                    # parent zones of old level-0 zone(s)?
4732
4733                base.setExplorationStatus(
4734                    now,
4735                    destID,
4736                    'noticed',
4737                    upgradeOnly=True
4738                )
4739                # TODO: Some way to specify 'hypothesized' here instead?
4740
4741        else:
4742            # in normal mode, 'DiscreteExploration.warp' takes care of
4743            # creating the decision if needed
4744            whichFocus = None
4745            if self.context['focus'] is not None:
4746                whichFocus = (
4747                    self.context['context'],
4748                    self.context['domain'],
4749                    self.context['focus']
4750                )
4751            if destination is None:
4752                destination = destID
4753
4754            if isinstance(destination, base.DecisionSpecifier):
4755                newZone = destination.zone
4756                if destination.domain is not None:
4757                    newDomain = destination.domain
4758            else:
4759                newZone = base.DefaultZone
4760
4761            destID = self.exploration.warp(
4762                destination,
4763                domain=newDomain,
4764                zone=newZone,
4765                whichFocus=whichFocus,
4766                inCommon=self.context['context'] == 'common',
4767                decisionType=decisionType
4768            )
4769            self.autoFinalizeExplorationStatuses()
4770
4771        self.context['decision'] = destID
4772        self.context['transition'] = None
4773
4774    def recordWait(
4775        self,
4776        decisionType: base.DecisionType = 'active'
4777    ) -> None:
4778        """
4779        Records a wait step. Does not modify the current transition.
4780        A non-standard decision type may be specified.
4781
4782        Raises a `JournalParseError` in relative mode, since it wouldn't
4783        have any effect.
4784        """
4785        if self.inRelativeMode:
4786            raise JournalParseError("Can't wait in relative mode.")
4787        else:
4788            self.exploration.wait(decisionType=decisionType)
4789
4790    def recordObserveEnding(self, name: base.DecisionName) -> None:
4791        """
4792        Records the observation of an action which warps to an ending,
4793        although unlike `recordEnd` we don't use that action yet. This
4794        does NOT update the current decision, although it sets the
4795        current transition to the action it creates.
4796
4797        The action created has the same name as the ending it warps to.
4798
4799        Note that normally, we just warp to endings, so there's no need
4800        to use `recordObserveEnding`. But if there's a player-controlled
4801        option to end the game at a particular node that is noticed
4802        before it's actually taken, this is the right thing to do.
4803
4804        We set up player-initiated ending transitions as actions with a
4805        goto rather than usual transitions because endings exist in a
4806        separate domain, and are active simultaneously with normal
4807        decisions.
4808        """
4809        graph = self.exploration.getSituation().graph
4810        here = self.definiteDecisionTarget()
4811        # Add the ending decision or grab the ID of the existing ending
4812        eID = graph.endingID(name)
4813        # Create action & add goto consequence
4814        graph.addAction(here, name)
4815        graph.setConsequence(here, name, [base.effect(goto=eID)])
4816        # Set the exploration status
4817        self.exploration.setExplorationStatus(
4818            eID,
4819            'noticed',
4820            upgradeOnly=True
4821        )
4822        self.context['transition'] = (here, name)
4823        # TODO: Prevent things like adding unexplored nodes to the
4824        # an ending...
4825
4826    def recordEnd(
4827        self,
4828        name: base.DecisionName,
4829        voluntary: bool = False,
4830        decisionType: Optional[base.DecisionType] = None
4831    ) -> None:
4832        """
4833        Records an ending. If `voluntary` is `False` (the default) then
4834        this becomes a warp that activates the specified ending (which
4835        is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave
4836        the current decision).
4837
4838        If `voluntary` is `True` then we also record an action with a
4839        'goto' effect that activates the specified ending, and record an
4840        exploration step that takes that action, instead of just a warp
4841        (`recordObserveEnding` would set up such an action without
4842        taking it).
4843
4844        The specified ending decision is created if it didn't already
4845        exist. If `voluntary` is True and an action that warps to the
4846        specified ending already exists with the correct name, we will
4847        simply take that action.
4848
4849        If it created an action, it sets the current transition to the
4850        action that warps to the ending. Endings are not added to zones;
4851        otherwise it sets the current transition to None.
4852
4853        In relative mode, an ending is still added, possibly with an
4854        action that warps to it, and the current decision is set to that
4855        ending node, but the transition doesn't actually get taken.
4856
4857        If not in relative mode, sets the exploration status of the
4858        current decision to `explored` if it wasn't in the
4859        `dontFinalize` set, even though we do not deactivate that
4860        transition.
4861
4862        When `voluntary` is not set, the decision type for the warp will
4863        be 'imposed', otherwise it will be 'active'. However, if an
4864        explicit `decisionType` is specified, that will override these
4865        defaults.
4866        """
4867        graph = self.exploration.getSituation().graph
4868        here = self.definiteDecisionTarget()
4869
4870        # Add our warping action if we need to
4871        if voluntary:
4872            # If voluntary, check for an existing warp action and set
4873            # one up if we don't have one.
4874            aDest = graph.getDestination(here, name)
4875            eID = graph.getDecision(
4876                base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name)
4877            )
4878            if aDest is None:
4879                # Okay we can just create the action
4880                self.recordObserveEnding(name)
4881                # else check if the existing transition is an action
4882                # that warps to the correct ending already
4883            elif (
4884                aDest != here
4885             or eID is None
4886             or not any(
4887                    c == base.effect(goto=eID)
4888                    for c in graph.getConsequence(here, name)
4889                )
4890            ):
4891                raise JournalParseError(
4892                    f"Attempting to add voluntary ending {name!r} at"
4893                    f" decision {graph.identityOf(here)} but that"
4894                    f" decision already has an action with that name"
4895                    f" and it's not set up to warp to that ending"
4896                    f" already."
4897                )
4898
4899        # Grab ending ID (creates the decision if necessary)
4900        eID = graph.endingID(name)
4901
4902        # Update our context variables
4903        self.context['decision'] = eID
4904        if voluntary:
4905            self.context['transition'] = (here, name)
4906        else:
4907            self.context['transition'] = None
4908
4909        # Update exploration status in relative mode, or possibly take
4910        # action in normal mode
4911        if self.inRelativeMode:
4912            self.exploration.setExplorationStatus(
4913                eID,
4914                "noticed",
4915                upgradeOnly=True
4916            )
4917        else:
4918            # Either take the action we added above, or just warp
4919            if decisionType is None:
4920                decisionType = 'active' if voluntary else 'imposed'
4921
4922            if voluntary:
4923                # Taking the action warps us to the ending
4924                self.exploration.takeAction(
4925                    name,
4926                    decisionType=decisionType
4927                )
4928            else:
4929                # We'll use a warp to get there
4930                self.exploration.warp(
4931                    base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name),
4932                    zone=None,
4933                    decisionType=decisionType
4934                )
4935                if (
4936                    here not in self.dontFinalize
4937                and (
4938                        self.exploration.getExplorationStatus(here)
4939                     == "exploring"
4940                    )
4941                ):
4942                    self.exploration.setExplorationStatus(here, "explored")
4943        # TODO: Prevent things like adding unexplored nodes to the
4944        # ending...
4945
4946    def recordMechanism(
4947        self,
4948        where: Optional[base.AnyDecisionSpecifier],
4949        name: base.MechanismName,
4950        startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE
4951    ) -> None:
4952        """
4953        Records the existence of a mechanism at the specified decision
4954        with the specified starting state (or the default starting
4955        state). Set `where` to `None` to set up a global mechanism that's
4956        not tied to any particular decision.
4957        """
4958        graph = self.exploration.getSituation().graph
4959        # TODO: a way to set up global mechanisms
4960        newID = graph.addMechanism(name, where)
4961        if startingState != base.DEFAULT_MECHANISM_STATE:
4962            self.exploration.setMechanismStateNow(newID, startingState)
4963
4964    def recordRequirement(self, req: Union[base.Requirement, str]) -> None:
4965        """
4966        Records a requirement observed on the most recently
4967        defined/taken transition. If a string is given,
4968        `ParseFormat.parseRequirement` will be used to parse it.
4969        """
4970        if isinstance(req, str):
4971            req = self.parseFormat.parseRequirement(req)
4972        target = self.currentTransitionTarget()
4973        if target is None:
4974            raise JournalParseError(
4975                "Can't set a requirement because there is no current"
4976                " transition."
4977            )
4978        graph = self.exploration.getSituation().graph
4979        graph.setTransitionRequirement(
4980            *target,
4981            req
4982        )
4983
4984    def recordReciprocalRequirement(
4985        self,
4986        req: Union[base.Requirement, str]
4987    ) -> None:
4988        """
4989        Records a requirement observed on the reciprocal of the most
4990        recently defined/taken transition. If a string is given,
4991        `ParseFormat.parseRequirement` will be used to parse it.
4992        """
4993        if isinstance(req, str):
4994            req = self.parseFormat.parseRequirement(req)
4995        target = self.currentReciprocalTarget()
4996        if target is None:
4997            raise JournalParseError(
4998                "Can't set a reciprocal requirement because there is no"
4999                " current transition or it doesn't have a reciprocal."
5000            )
5001        graph = self.exploration.getSituation().graph
5002        graph.setTransitionRequirement(*target, req)
5003
5004    def recordTransitionConsequence(
5005        self,
5006        consequence: base.Consequence
5007    ) -> None:
5008        """
5009        Records a transition consequence, which gets added to any
5010        existing consequences of the currently-relevant transition (the
5011        most-recently created or taken transition). A `JournalParseError`
5012        will be raised if there is no current transition.
5013        """
5014        target = self.currentTransitionTarget()
5015        if target is None:
5016            raise JournalParseError(
5017                "Cannot apply a consequence because there is no current"
5018                " transition."
5019            )
5020
5021        now = self.exploration.getSituation()
5022        now.graph.addConsequence(*target, consequence)
5023
5024    def recordReciprocalConsequence(
5025        self,
5026        consequence: base.Consequence
5027    ) -> None:
5028        """
5029        Like `recordTransitionConsequence` but applies the effect to the
5030        reciprocal of the current transition. Will cause a
5031        `JournalParseError` if the current transition has no reciprocal
5032        (e.g., it's an ending transition).
5033        """
5034        target = self.currentReciprocalTarget()
5035        if target is None:
5036            raise JournalParseError(
5037                "Cannot apply a reciprocal effect because there is no"
5038                " current transition, or it doesn't have a reciprocal."
5039            )
5040
5041        now = self.exploration.getSituation()
5042        now.graph.addConsequence(*target, consequence)
5043
5044    def recordAdditionalTransitionConsequence(
5045        self,
5046        consequence: base.Consequence,
5047        hideEffects: bool = True
5048    ) -> None:
5049        """
5050        Records the addition of a new consequence to the current
5051        relevant transition, while also triggering the effects of that
5052        consequence (but not the other effects of that transition, which
5053        we presume have just been applied already).
5054
5055        By default each effect added this way automatically gets the
5056        "hidden" property added to it, because the assumption is if it
5057        were a foreseeable effect, you would have added it to the
5058        transition before taking it. If you set `hideEffects` to
5059        `False`, this won't be done.
5060
5061        This modifies the current state but does not add a step to the
5062        exploration. It does NOT call `autoFinalizeExplorationStatuses`,
5063        which means that if a 'bounce' or 'goto' effect ends up making
5064        one or more decisions no-longer-active, they do NOT get their
5065        exploration statuses upgraded to 'explored'.
5066        """
5067        # Receive begin/end indices from `addConsequence` and send them
5068        # to `applyTransitionConsequence` to limit which # parts of the
5069        # expanded consequence are actually applied.
5070        currentTransition = self.currentTransitionTarget()
5071        if currentTransition is None:
5072            consRepr = self.parseFormat.unparseConsequence(consequence)
5073            raise JournalParseError(
5074                f"Can't apply an additional consequence to a transition"
5075                f" when there is no current transition. Got"
5076                f" consequence:\n{consRepr}"
5077            )
5078
5079        if hideEffects:
5080            for (index, item) in base.walkParts(consequence):
5081                if isinstance(item, dict) and 'value' in item:
5082                    assert 'hidden' in item
5083                    item = cast(base.Effect, item)
5084                    item['hidden'] = True
5085
5086        now = self.exploration.getSituation()
5087        begin, end = now.graph.addConsequence(
5088            *currentTransition,
5089            consequence
5090        )
5091        self.exploration.applyTransitionConsequence(
5092            *currentTransition,
5093            moveWhich=self.context['focus'],
5094            policy="specified",
5095            fromIndex=begin,
5096            toIndex=end
5097        )
5098        # This tracks trigger counts and obeys
5099        # charges/delays, unlike
5100        # applyExtraneousConsequence, but some effects
5101        # like 'bounce' still can't be properly applied
5102
5103    def recordTagStep(
5104        self,
5105        tag: base.Tag,
5106        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5107    ) -> None:
5108        """
5109        Records a tag to be applied to the current exploration step.
5110        """
5111        self.exploration.tagStep(tag, value)
5112
5113    def recordTagDecision(
5114        self,
5115        tag: base.Tag,
5116        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5117    ) -> None:
5118        """
5119        Records a tag to be applied to the current decision.
5120        """
5121        now = self.exploration.getSituation()
5122        now.graph.tagDecision(
5123            self.definiteDecisionTarget(),
5124            tag,
5125            value
5126        )
5127
5128    def recordTagTranstion(
5129        self,
5130        tag: base.Tag,
5131        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5132    ) -> None:
5133        """
5134        Records a tag to be applied to the most-recently-defined or
5135        -taken transition.
5136        """
5137        target = self.currentTransitionTarget()
5138        if target is None:
5139            raise JournalParseError(
5140                "Cannot tag a transition because there is no current"
5141                " transition."
5142            )
5143
5144        now = self.exploration.getSituation()
5145        now.graph.tagTransition(*target, tag, value)
5146
5147    def recordTagReciprocal(
5148        self,
5149        tag: base.Tag,
5150        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5151    ) -> None:
5152        """
5153        Records a tag to be applied to the reciprocal of the
5154        most-recently-defined or -taken transition.
5155        """
5156        target = self.currentReciprocalTarget()
5157        if target is None:
5158            raise JournalParseError(
5159                "Cannot tag a transition because there is no current"
5160                " transition."
5161            )
5162
5163        now = self.exploration.getSituation()
5164        now.graph.tagTransition(*target, tag, value)
5165
5166    def currentZoneAtLevel(self, level: int) -> base.Zone:
5167        """
5168        Returns a zone in the current graph that applies to the current
5169        decision which is at the specified hierarchy level. If there is
5170        no such zone, raises a `JournalParseError`. If there are
5171        multiple such zones, returns the zone which includes the fewest
5172        decisions, breaking ties alphabetically by zone name.
5173        """
5174        here = self.definiteDecisionTarget()
5175        graph = self.exploration.getSituation().graph
5176        ancestors = graph.zoneAncestors(here)
5177        candidates = [
5178            ancestor
5179            for ancestor in ancestors
5180            if graph.zoneHierarchyLevel(ancestor) == level
5181        ]
5182        if len(candidates) == 0:
5183            raise JournalParseError(
5184                (
5185                    f"Cannot find any level-{level} zones for the"
5186                    f" current decision {graph.identityOf(here)}. That"
5187                    f" decision is"
5188                ) + (
5189                    " in the following zones:"
5190                  + '\n'.join(
5191                        f"  level {graph.zoneHierarchyLevel(z)}: {z!r}"
5192                        for z in ancestors
5193                    )
5194                ) if len(ancestors) > 0 else (
5195                    " not in any zones."
5196                )
5197            )
5198        candidates.sort(
5199            key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone)
5200        )
5201        return candidates[0]
5202
5203    def recordTagZone(
5204        self,
5205        level: int,
5206        tag: base.Tag,
5207        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5208    ) -> None:
5209        """
5210        Records a tag to be applied to one of the zones that the current
5211        decision is in, at a specific hierarchy level. There must be at
5212        least one zone ancestor of the current decision at that hierarchy
5213        level; if there are multiple then the tag is applied to the
5214        smallest one, breaking ties by alphabetical order.
5215        """
5216        applyTo = self.currentZoneAtLevel(level)
5217        self.exploration.getSituation().graph.tagZone(applyTo, tag, value)
5218
5219    def recordAnnotateStep(
5220        self,
5221        *annotations: base.Annotation
5222    ) -> None:
5223        """
5224        Records annotations to be applied to the current exploration
5225        step.
5226        """
5227        self.exploration.annotateStep(annotations)
5228        pf = self.parseFormat
5229        now = self.exploration.getSituation()
5230        for a in annotations:
5231            if a.startswith("at:"):
5232                expects = pf.parseDecisionSpecifier(a[3:])
5233                if isinstance(expects, base.DecisionSpecifier):
5234                    if expects.domain is None and expects.zone is None:
5235                        expects = base.spliceDecisionSpecifiers(
5236                            expects,
5237                            self.decisionTargetSpecifier()
5238                        )
5239                eID = now.graph.getDecision(expects)
5240                primaryNow: Optional[base.DecisionID]
5241                if self.inRelativeMode:
5242                    primaryNow = self.definiteDecisionTarget()
5243                else:
5244                    primaryNow = now.state['primaryDecision']
5245                if eID is None:
5246                    self.warn(
5247                        f"'at' annotation expects position {expects!r}"
5248                        f" but that's not a valid decision specifier in"
5249                        f" the current graph."
5250                    )
5251                elif eID != primaryNow:
5252                    self.warn(
5253                        f"'at' annotation expects position {expects!r}"
5254                        f" which is decision"
5255                        f" {now.graph.identityOf(eID)}, but the current"
5256                        f" primary decision is"
5257                        f" {now.graph.identityOf(primaryNow)}"
5258                    )
5259            elif a.startswith("active:"):
5260                expects = pf.parseDecisionSpecifier(a[3:])
5261                eID = now.graph.getDecision(expects)
5262                atNow = base.combinedDecisionSet(now.state)
5263                if eID is None:
5264                    self.warn(
5265                        f"'active' annotation expects decision {expects!r}"
5266                        f" but that's not a valid decision specifier in"
5267                        f" the current graph."
5268                    )
5269                elif eID not in atNow:
5270                    self.warn(
5271                        f"'active' annotation expects decision {expects!r}"
5272                        f" which is {now.graph.identityOf(eID)}, but"
5273                        f" the current active position(s) is/are:"
5274                        f"\n{now.graph.namesListing(atNow)}"
5275                    )
5276            elif a.startswith("has:"):
5277                ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0]
5278                if (
5279                    isinstance(ea, tuple)
5280                and len(ea) == 2
5281                and isinstance(ea[0], base.Token)
5282                and isinstance(ea[1], base.TokenCount)
5283                ):
5284                    countNow = base.combinedTokenCount(now.state, ea[0])
5285                    if countNow != ea[1]:
5286                        self.warn(
5287                            f"'has' annotation expects {ea[1]} {ea[0]!r}"
5288                            f" token(s) but the current state has"
5289                            f" {countNow} of them."
5290                        )
5291                else:
5292                    self.warn(
5293                        f"'has' annotation expects tokens {a[4:]!r} but"
5294                        f" that's not a (token, count) pair."
5295                    )
5296            elif a.startswith("level:"):
5297                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5298                if (
5299                    isinstance(ea, tuple)
5300                and len(ea) == 3
5301                and ea[0] == 'skill'
5302                and isinstance(ea[1], base.Skill)
5303                and isinstance(ea[2], base.Level)
5304                ):
5305                    levelNow = base.getSkillLevel(now.state, ea[1])
5306                    if levelNow != ea[2]:
5307                        self.warn(
5308                            f"'level' annotation expects skill {ea[1]!r}"
5309                            f" to be at level {ea[2]} but the current"
5310                            f" level for that skill is {levelNow}."
5311                        )
5312                else:
5313                    self.warn(
5314                        f"'level' annotation expects skill {a[6:]!r} but"
5315                        f" that's not a (skill, level) pair."
5316                    )
5317            elif a.startswith("can:"):
5318                try:
5319                    req = pf.parseRequirement(a[4:])
5320                except parsing.ParseError:
5321                    self.warn(
5322                        f"'can' annotation expects requirement {a[4:]!r}"
5323                        f" but that's not parsable as a requirement."
5324                    )
5325                    req = None
5326                if req is not None:
5327                    ctx = base.genericContextForSituation(now)
5328                    if not req.satisfied(ctx):
5329                        self.warn(
5330                            f"'can' annotation expects requirement"
5331                            f" {req!r} to be satisfied but it's not in"
5332                            f" the current situation."
5333                        )
5334            elif a.startswith("state:"):
5335                ctx = base.genericContextForSituation(
5336                    now
5337                )
5338                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5339                if (
5340                    isinstance(ea, tuple)
5341                and len(ea) == 2
5342                and isinstance(ea[0], tuple)
5343                and len(ea[0]) == 4
5344                and (ea[0][0] is None or isinstance(ea[0][0], base.Domain))
5345                and (ea[0][1] is None or isinstance(ea[0][1], base.Zone))
5346                and (
5347                        ea[0][2] is None
5348                     or isinstance(ea[0][2], base.DecisionName)
5349                    )
5350                and isinstance(ea[0][3], base.MechanismName)
5351                and isinstance(ea[1], base.MechanismState)
5352                ):
5353                    mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom)
5354                    stateNow = base.stateOfMechanism(ctx, mID)
5355                    if not base.mechanismInStateOrEquivalent(
5356                        mID,
5357                        ea[1],
5358                        ctx
5359                    ):
5360                        self.warn(
5361                            f"'state' annotation expects mechanism {mID}"
5362                            f" {ea[0]!r} to be in state {ea[1]!r} but"
5363                            f" its current state is {stateNow!r} and no"
5364                            f" equivalence makes it count as being in"
5365                            f" state {ea[1]!r}."
5366                        )
5367                else:
5368                    self.warn(
5369                        f"'state' annotation expects mechanism state"
5370                        f" {a[6:]!r} but that's not a mechanism/state"
5371                        f" pair."
5372                    )
5373            elif a.startswith("exists:"):
5374                expects = pf.parseDecisionSpecifier(a[7:])
5375                try:
5376                    now.graph.resolveDecision(expects)
5377                except core.MissingDecisionError:
5378                    self.warn(
5379                        f"'exists' annotation expects decision"
5380                        f" {a[7:]!r} but that decision does not exist."
5381                    )
5382
5383    def recordAnnotateDecision(
5384        self,
5385        *annotations: base.Annotation
5386    ) -> None:
5387        """
5388        Records annotations to be applied to the current decision.
5389        """
5390        now = self.exploration.getSituation()
5391        now.graph.annotateDecision(self.definiteDecisionTarget(), annotations)
5392
5393    def recordAnnotateTranstion(
5394        self,
5395        *annotations: base.Annotation
5396    ) -> None:
5397        """
5398        Records annotations to be applied to the most-recently-defined
5399        or -taken transition.
5400        """
5401        target = self.currentTransitionTarget()
5402        if target is None:
5403            raise JournalParseError(
5404                "Cannot annotate a transition because there is no"
5405                " current transition."
5406            )
5407
5408        now = self.exploration.getSituation()
5409        now.graph.annotateTransition(*target, annotations)
5410
5411    def recordAnnotateReciprocal(
5412        self,
5413        *annotations: base.Annotation
5414    ) -> None:
5415        """
5416        Records annotations to be applied to the reciprocal of the
5417        most-recently-defined or -taken transition.
5418        """
5419        target = self.currentReciprocalTarget()
5420        if target is None:
5421            raise JournalParseError(
5422                "Cannot annotate a reciprocal because there is no"
5423                " current transition or because it doens't have a"
5424                " reciprocal."
5425            )
5426
5427        now = self.exploration.getSituation()
5428        now.graph.annotateTransition(*target, annotations)
5429
5430    def recordAnnotateZone(
5431        self,
5432        level,
5433        *annotations: base.Annotation
5434    ) -> None:
5435        """
5436        Records annotations to be applied to the zone at the specified
5437        hierarchy level which contains the current decision. If there are
5438        multiple such zones, it picks the smallest one, breaking ties
5439        alphabetically by zone name (see `currentZoneAtLevel`).
5440        """
5441        applyTo = self.currentZoneAtLevel(level)
5442        self.exploration.getSituation().graph.annotateZone(
5443            applyTo,
5444            annotations
5445        )
5446
5447    def recordContextSwap(
5448        self,
5449        targetContext: Optional[base.FocalContextName]
5450    ) -> None:
5451        """
5452        Records a swap of the active focal context, and/or a swap into
5453        "common"-context mode where all effects modify the common focal
5454        context instead of the active one. Use `None` as the argument to
5455        swap to common mode; use another specific value so swap to
5456        normal mode and set that context as the active one.
5457
5458        In relative mode, swaps the active context without adding an
5459        exploration step. Swapping into the common context never results
5460        in a new exploration step.
5461        """
5462        if targetContext is None:
5463            self.context['context'] = "common"
5464        else:
5465            self.context['context'] = "active"
5466            e = self.getExploration()
5467            if self.inRelativeMode:
5468                e.setActiveContext(targetContext)
5469            else:
5470                e.advanceSituation(('swap', targetContext))
5471
5472    def recordZone(self, level: int, zone: base.Zone) -> None:
5473        """
5474        Records a new current zone to be swapped with the zone(s) at the
5475        specified hierarchy level for the current decision target. See
5476        `core.DiscreteExploration.reZone` and
5477        `core.DecisionGraph.replaceZonesInHierarchy` for details on what
5478        exactly happens; the summary is that the zones at the specified
5479        hierarchy level are replaced with the provided zone (which is
5480        created if necessary) and their children are re-parented onto
5481        the provided zone, while that zone is also set as a child of
5482        their parents.
5483
5484        Does the same thing in relative mode as in normal mode.
5485        """
5486        self.exploration.reZone(
5487            zone,
5488            self.definiteDecisionTarget(),
5489            level
5490        )
5491
5492    def recordUnify(
5493        self,
5494        merge: base.AnyDecisionSpecifier,
5495        mergeInto: Optional[base.AnyDecisionSpecifier] = None
5496    ) -> None:
5497        """
5498        Records a unification between two decisions. This marks an
5499        observation that they are actually the same decision and it
5500        merges them. If only one decision is given the current decision
5501        is merged into that one. After the merge, the first decision (or
5502        the current decision if only one was given) will no longer
5503        exist.
5504
5505        If one of the merged decisions was the current position in a
5506        singular-focalized domain, or one of the current positions in a
5507        plural- or spreading-focalized domain, the merged decision will
5508        replace it as a current decision after the merge, and this
5509        happens even when in relative mode. The target decision is also
5510        updated if it needs to be.
5511
5512        A `TransitionCollisionError` will be raised if the two decisions
5513        have outgoing transitions that share a name.
5514
5515        Logs a `JournalParseWarning` if the two decisions were in
5516        different zones.
5517
5518        Any transitions between the two merged decisions will remain in
5519        place as actions.
5520
5521        TODO: Option for removing self-edges after the merge? Option for
5522        doing that for just effect-less edges?
5523        """
5524        if mergeInto is None:
5525            mergeInto = merge
5526            merge = self.definiteDecisionTarget()
5527
5528        if isinstance(merge, str):
5529            merge = self.parseFormat.parseDecisionSpecifier(merge)
5530
5531        if isinstance(mergeInto, str):
5532            mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto)
5533
5534        now = self.exploration.getSituation()
5535
5536        if not isinstance(merge, base.DecisionID):
5537            merge = now.graph.resolveDecision(merge)
5538
5539        merge = cast(base.DecisionID, merge)
5540
5541        now.graph.mergeDecisions(merge, mergeInto)
5542
5543        mergedID = now.graph.resolveDecision(mergeInto)
5544
5545        # Update FocalContexts & ObservationContexts as necessary
5546        self.cleanupContexts(remapped={merge: mergedID})
5547
5548    def recordUnifyTransition(self, target: base.Transition) -> None:
5549        """
5550        Records a unification between the most-recently-defined or
5551        -taken transition and the specified transition (which must be
5552        outgoing from the same decision). This marks an observation that
5553        two transitions are actually the same transition and it merges
5554        them.
5555
5556        After the merge, the target transition will still exist but the
5557        previously most-recent transition will have been deleted.
5558
5559        Their reciprocals will also be merged.
5560
5561        A `JournalParseError` is raised if there is no most-recent
5562        transition.
5563        """
5564        now = self.exploration.getSituation()
5565        graph = now.graph
5566        affected = self.currentTransitionTarget()
5567        if affected is None or affected[1] is None:
5568            raise JournalParseError(
5569                "Cannot unify transitions: there is no current"
5570                " transition."
5571            )
5572
5573        decision, transition = affected
5574
5575        # If they don't share a target, then the current transition must
5576        # lead to an unknown node, which we will dispose of
5577        destination = graph.getDestination(decision, transition)
5578        if destination is None:
5579            raise JournalParseError(
5580                f"Cannot unify transitions: transition"
5581                f" {transition!r} at decision"
5582                f" {graph.identityOf(decision)} has no destination."
5583            )
5584
5585        finalDestination = graph.getDestination(decision, target)
5586        if finalDestination is None:
5587            raise JournalParseError(
5588                f"Cannot unify transitions: transition"
5589                f" {target!r} at decision {graph.identityOf(decision)}"
5590                f" has no destination."
5591            )
5592
5593        if destination != finalDestination:
5594            if graph.isConfirmed(destination):
5595                raise JournalParseError(
5596                    f"Cannot unify transitions: destination"
5597                    f" {graph.identityOf(destination)} of transition"
5598                    f" {transition!r} at decision"
5599                    f" {graph.identityOf(decision)} is not an"
5600                    f" unconfirmed decision."
5601                )
5602            # Retarget and delete the unknown node that we abandon
5603            # TODO: Merge nodes instead?
5604            now.graph.retargetTransition(
5605                decision,
5606                transition,
5607                finalDestination
5608            )
5609            now.graph.removeDecision(destination)
5610
5611        # Now we can merge transitions
5612        now.graph.mergeTransitions(decision, transition, target)
5613
5614        # Update targets if they were merged
5615        self.cleanupContexts(
5616            remappedTransitions={
5617                (decision, transition): (decision, target)
5618            }
5619        )
5620
5621    def recordUnifyReciprocal(
5622        self,
5623        target: base.Transition
5624    ) -> None:
5625        """
5626        Records a unification between the reciprocal of the
5627        most-recently-defined or -taken transition and the specified
5628        transition, which must be outgoing from the current transition's
5629        destination. This marks an observation that two transitions are
5630        actually the same transition and it merges them, deleting the
5631        original reciprocal. Note that the current transition will also
5632        be merged with the reciprocal of the target.
5633
5634        A `JournalParseError` is raised if there is no current
5635        transition, or if it does not have a reciprocal.
5636        """
5637        now = self.exploration.getSituation()
5638        graph = now.graph
5639        affected = self.currentReciprocalTarget()
5640        if affected is None or affected[1] is None:
5641            raise JournalParseError(
5642                "Cannot unify transitions: there is no current"
5643                " transition."
5644            )
5645
5646        decision, transition = affected
5647
5648        destination = graph.destination(decision, transition)
5649        reciprocal = graph.getReciprocal(decision, transition)
5650        if reciprocal is None:
5651            raise JournalParseError(
5652                "Cannot unify reciprocal: there is no reciprocal of the"
5653                " current transition."
5654            )
5655
5656        # If they don't share a target, then the current transition must
5657        # lead to an unknown node, which we will dispose of
5658        finalDestination = graph.getDestination(destination, target)
5659        if finalDestination is None:
5660            raise JournalParseError(
5661                f"Cannot unify reciprocal: transition"
5662                f" {target!r} at decision"
5663                f" {graph.identityOf(destination)} has no destination."
5664            )
5665
5666        if decision != finalDestination:
5667            if graph.isConfirmed(decision):
5668                raise JournalParseError(
5669                    f"Cannot unify reciprocal: destination"
5670                    f" {graph.identityOf(decision)} of transition"
5671                    f" {reciprocal!r} at decision"
5672                    f" {graph.identityOf(destination)} is not an"
5673                    f" unconfirmed decision."
5674                )
5675            # Retarget and delete the unknown node that we abandon
5676            # TODO: Merge nodes instead?
5677            graph.retargetTransition(
5678                destination,
5679                reciprocal,
5680                finalDestination
5681            )
5682            graph.removeDecision(decision)
5683
5684        # Actually merge the transitions
5685        graph.mergeTransitions(destination, reciprocal, target)
5686
5687        # Update targets if they were merged
5688        self.cleanupContexts(
5689            remappedTransitions={
5690                (decision, transition): (decision, target)
5691            }
5692        )
5693
5694    def recordObviate(
5695        self,
5696        transition: base.Transition,
5697        otherDecision: base.AnyDecisionSpecifier,
5698        otherTransition: base.Transition
5699    ) -> None:
5700        """
5701        Records the obviation of a transition at another decision. This
5702        is the observation that a specific transition at the current
5703        decision is the reciprocal of a different transition at another
5704        decision which previously led to an unknown area. The difference
5705        between this and `recordReturn` is that `recordReturn` logs
5706        movement across the newly-connected transition, while this
5707        leaves the player at their original decision (and does not even
5708        add a step to the current exploration).
5709
5710        Both transitions will be created if they didn't already exist.
5711
5712        In relative mode does the same thing and doesn't move the current
5713        decision across the transition updated.
5714
5715        If the destination is unknown, it will remain unknown after this
5716        operation.
5717        """
5718        now = self.exploration.getSituation()
5719        graph = now.graph
5720        here = self.definiteDecisionTarget()
5721
5722        if isinstance(otherDecision, str):
5723            otherDecision = self.parseFormat.parseDecisionSpecifier(
5724                otherDecision
5725            )
5726
5727        # If we started with a name or some other kind of decision
5728        # specifier, replace missing domain and/or zone info with info
5729        # from the current decision.
5730        if isinstance(otherDecision, base.DecisionSpecifier):
5731            otherDecision = base.spliceDecisionSpecifiers(
5732                otherDecision,
5733                self.decisionTargetSpecifier()
5734            )
5735
5736        otherDestination = graph.getDestination(
5737            otherDecision,
5738            otherTransition
5739        )
5740        if otherDestination is not None:
5741            if graph.isConfirmed(otherDestination):
5742                raise JournalParseError(
5743                    f"Cannot obviate transition {otherTransition!r} at"
5744                    f" decision {graph.identityOf(otherDecision)}: that"
5745                    f" transition leads to decision"
5746                    f" {graph.identityOf(otherDestination)} which has"
5747                    f" already been visited."
5748                )
5749        else:
5750            # We must create the other destination
5751            graph.addUnexploredEdge(otherDecision, otherTransition)
5752
5753        destination = graph.getDestination(here, transition)
5754        if destination is not None:
5755            if graph.isConfirmed(destination):
5756                raise JournalParseError(
5757                    f"Cannot obviate using transition {transition!r} at"
5758                    f" decision {graph.identityOf(here)}: that"
5759                    f" transition leads to decision"
5760                    f" {graph.identityOf(destination)} which is not an"
5761                    f" unconfirmed decision."
5762                )
5763        else:
5764            # we need to create it
5765            graph.addUnexploredEdge(here, transition)
5766
5767        # Track exploration status of destination (because
5768        # `replaceUnconfirmed` will overwrite it but we want to preserve
5769        # it in this case.
5770        if otherDecision is not None:
5771            prevStatus = base.explorationStatusOf(now, otherDecision)
5772
5773        # Now connect the transitions and clean up the unknown nodes
5774        graph.replaceUnconfirmed(
5775            here,
5776            transition,
5777            otherDecision,
5778            otherTransition
5779        )
5780        # Restore exploration status
5781        base.setExplorationStatus(now, otherDecision, prevStatus)
5782
5783        # Update context
5784        self.context['transition'] = (here, transition)
5785
5786    def cleanupContexts(
5787        self,
5788        remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None,
5789        remappedTransitions: Optional[
5790            Dict[
5791                Tuple[base.DecisionID, base.Transition],
5792                Tuple[base.DecisionID, base.Transition]
5793            ]
5794        ] = None
5795    ) -> None:
5796        """
5797        Checks the validity of context decision and transition entries,
5798        and sets them to `None` in situations where they are no longer
5799        valid, affecting both the current and stored contexts.
5800
5801        Also updates position information in focal contexts in the
5802        current exploration step.
5803
5804        If a `remapped` dictionary is provided, decisions in the keys of
5805        that dictionary will be replaced with the corresponding value
5806        before being checked.
5807
5808        Similarly a `remappedTransitions` dicitonary may provide info on
5809        renamed transitions using (`base.DecisionID`, `base.Transition`)
5810        pairs as both keys and values.
5811        """
5812        if remapped is None:
5813            remapped = {}
5814
5815        if remappedTransitions is None:
5816            remappedTransitions = {}
5817
5818        # Fix broken position information in the current focal contexts
5819        now = self.exploration.getSituation()
5820        graph = now.graph
5821        state = now.state
5822        for ctx in (
5823            state['common'],
5824            state['contexts'][state['activeContext']]
5825        ):
5826            active = ctx['activeDecisions']
5827            for domain in active:
5828                aVal = active[domain]
5829                if isinstance(aVal, base.DecisionID):
5830                    if aVal in remapped:  # check for remap
5831                        aVal = remapped[aVal]
5832                        active[domain] = aVal
5833                    if graph.getDecision(aVal) is None: # Ultimately valid?
5834                        active[domain] = None
5835                elif isinstance(aVal, dict):
5836                    for fpName in aVal:
5837                        fpVal = aVal[fpName]
5838                        if fpVal is None:
5839                            aVal[fpName] = None
5840                        elif fpVal in remapped:  # check for remap
5841                            aVal[fpName] = remapped[fpVal]
5842                        elif graph.getDecision(fpVal) is None:  # valid?
5843                            aVal[fpName] = None
5844                elif isinstance(aVal, set):
5845                    for r in remapped:
5846                        if r in aVal:
5847                            aVal.remove(r)
5848                            aVal.add(remapped[r])
5849                    discard = []
5850                    for dID in aVal:
5851                        if graph.getDecision(dID) is None:
5852                            discard.append(dID)
5853                    for dID in discard:
5854                        aVal.remove(dID)
5855                elif aVal is not None:
5856                    raise RuntimeError(
5857                        f"Invalid active decisions for domain"
5858                        f" {repr(domain)}: {repr(aVal)}"
5859                    )
5860
5861        # Fix up our ObservationContexts
5862        fix = [self.context]
5863        if self.storedContext is not None:
5864            fix.append(self.storedContext)
5865
5866        graph = self.exploration.getSituation().graph
5867        for obsCtx in fix:
5868            cdID = obsCtx['decision']
5869            if cdID in remapped:
5870                cdID = remapped[cdID]
5871                obsCtx['decision'] = cdID
5872
5873            if cdID not in graph:
5874                obsCtx['decision'] = None
5875
5876            transition = obsCtx['transition']
5877            if transition is not None:
5878                tSourceID = transition[0]
5879                if tSourceID in remapped:
5880                    tSourceID = remapped[tSourceID]
5881                    obsCtx['transition'] = (tSourceID, transition[1])
5882
5883                if transition in remappedTransitions:
5884                    obsCtx['transition'] = remappedTransitions[transition]
5885
5886                tDestID = graph.getDestination(tSourceID, transition[1])
5887                if tDestID is None:
5888                    obsCtx['transition'] = None
5889
5890    def recordExtinguishDecision(
5891        self,
5892        target: base.AnyDecisionSpecifier
5893    ) -> None:
5894        """
5895        Records the deletion of a decision. The decision and all
5896        transitions connected to it will be removed from the current
5897        graph. Does not create a new exploration step. If the current
5898        position is deleted, the position will be set to `None`, or if
5899        we're in relative mode, the decision target will be set to
5900        `None` if it gets deleted. Likewise, all stored and/or current
5901        transitions which no longer exist are erased to `None`.
5902        """
5903        # Erase target if it's going to be removed
5904        now = self.exploration.getSituation()
5905
5906        if isinstance(target, str):
5907            target = self.parseFormat.parseDecisionSpecifier(target)
5908
5909        # TODO: Do we need to worry about the node being part of any
5910        # focal context data structures?
5911
5912        # Actually remove it
5913        now.graph.removeDecision(target)
5914
5915        # Clean up our contexts
5916        self.cleanupContexts()
5917
5918    def recordExtinguishTransition(
5919        self,
5920        source: base.AnyDecisionSpecifier,
5921        target: base.Transition,
5922        deleteReciprocal: bool = True
5923    ) -> None:
5924        """
5925        Records the deletion of a named transition coming from a
5926        specific source. The reciprocal will also be removed, unless
5927        `deleteReciprocal` is set to False. If `deleteReciprocal` is
5928        used and this results in the complete isolation of an unknown
5929        node, that node will be deleted as well. Cleans up any saved
5930        transition targets that are no longer valid by setting them to
5931        `None`. Does not create a graph step.
5932        """
5933        now = self.exploration.getSituation()
5934        graph = now.graph
5935        dest = graph.destination(source, target)
5936
5937        # Remove the transition
5938        graph.removeTransition(source, target, deleteReciprocal)
5939
5940        # Remove the old destination if it's unconfirmed and no longer
5941        # connected anywhere
5942        if (
5943            not graph.isConfirmed(dest)
5944        and len(graph.destinationsFrom(dest)) == 0
5945        ):
5946            graph.removeDecision(dest)
5947
5948        # Clean up our contexts
5949        self.cleanupContexts()
5950
5951    def recordComplicate(
5952        self,
5953        target: base.Transition,
5954        newDecision: base.DecisionName,  # TODO: Allow zones/domain here
5955        newReciprocal: Optional[base.Transition],
5956        newReciprocalReciprocal: Optional[base.Transition]
5957    ) -> base.DecisionID:
5958        """
5959        Records the complication of a transition and its reciprocal into
5960        a new decision. The old transition and its old reciprocal (if
5961        there was one) both point to the new decision. The
5962        `newReciprocal` becomes the new reciprocal of the original
5963        transition, and the `newReciprocalReciprocal` becomes the new
5964        reciprocal of the old reciprocal. Either may be set explicitly to
5965        `None` to leave the corresponding new transition without a
5966        reciprocal (but they don't default to `None`). If there was no
5967        old reciprocal, but `newReciprocalReciprocal` is specified, then
5968        that transition is created linking the new node to the old
5969        destination, without a reciprocal.
5970
5971        The current decision & transition information is not updated.
5972
5973        Returns the decision ID for the new node.
5974        """
5975        now = self.exploration.getSituation()
5976        graph = now.graph
5977        here = self.definiteDecisionTarget()
5978        domain = graph.domainFor(here)
5979
5980        oldDest = graph.destination(here, target)
5981        oldReciprocal = graph.getReciprocal(here, target)
5982
5983        # Create the new decision:
5984        newID = graph.addDecision(newDecision, domain=domain)
5985        # Note that the new decision is NOT an unknown decision
5986        # We copy the exploration status from the current decision
5987        self.exploration.setExplorationStatus(
5988            newID,
5989            self.exploration.getExplorationStatus(here)
5990        )
5991        # Copy over zone info
5992        for zp in graph.zoneParents(here):
5993            graph.addDecisionToZone(newID, zp)
5994
5995        # Retarget the transitions
5996        graph.retargetTransition(
5997            here,
5998            target,
5999            newID,
6000            swapReciprocal=False
6001        )
6002        if oldReciprocal is not None:
6003            graph.retargetTransition(
6004                oldDest,
6005                oldReciprocal,
6006                newID,
6007                swapReciprocal=False
6008            )
6009
6010        # Add a new reciprocal edge
6011        if newReciprocal is not None:
6012            graph.addTransition(newID, newReciprocal, here)
6013            graph.setReciprocal(here, target, newReciprocal)
6014
6015        # Add a new double-reciprocal edge (even if there wasn't a
6016        # reciprocal before)
6017        if newReciprocalReciprocal is not None:
6018            graph.addTransition(
6019                newID,
6020                newReciprocalReciprocal,
6021                oldDest
6022            )
6023            if oldReciprocal is not None:
6024                graph.setReciprocal(
6025                    oldDest,
6026                    oldReciprocal,
6027                    newReciprocalReciprocal
6028                )
6029
6030        return newID
6031
6032    def recordRevert(
6033        self,
6034        slot: base.SaveSlot,
6035        aspects: Set[str],
6036        decisionType: base.DecisionType = 'active'
6037    ) -> None:
6038        """
6039        Records a reversion to a previous state (possibly for only some
6040        aspects of the current state). See `base.revertedState` for the
6041        allowed values and meanings of strings in the aspects set.
6042        Uses the specified decision type, or 'active' by default.
6043
6044        Reversion counts as an exploration step.
6045
6046        This sets the current decision to the primary decision for the
6047        reverted state (which might be `None` in some cases) and sets
6048        the current transition to None.
6049        """
6050        self.exploration.revert(slot, aspects, decisionType=decisionType)
6051        newPrimary = self.exploration.getSituation().state['primaryDecision']
6052        self.context['decision'] = newPrimary
6053        self.context['transition'] = None
6054
6055    def recordFulfills(
6056        self,
6057        requirement: Union[str, base.Requirement],
6058        fulfilled: Union[
6059            base.Capability,
6060            Tuple[base.MechanismID, base.MechanismState]
6061        ]
6062    ) -> None:
6063        """
6064        Records the observation that a certain requirement fulfills the
6065        same role as (i.e., is equivalent to) a specific capability, or a
6066        specific mechanism being in a specific state. Transitions that
6067        require that capability or mechanism state will count as
6068        traversable even if that capability is not obtained or that
6069        mechanism is in another state, as long as the requirement for the
6070        fulfillment is satisfied. If multiple equivalences are
6071        established, any one of them being satisfied will count as that
6072        capability being obtained (or the mechanism being in the
6073        specified state). Note that if a circular dependency is created,
6074        the capability or mechanism (unless actually obtained or in the
6075        target state) will be considered as not being obtained (or in the
6076        target state) during recursive checks.
6077        """
6078        if isinstance(requirement, str):
6079            requirement = self.parseFormat.parseRequirement(requirement)
6080
6081        self.getExploration().getSituation().graph.addEquivalence(
6082            requirement,
6083            fulfilled
6084        )
6085
6086    def recordFocusOn(
6087        self,
6088        newFocalPoint: base.FocalPointName,
6089        inDomain: Optional[base.Domain] = None,
6090        inCommon: bool = False
6091    ):
6092        """
6093        Records a swap to a new focal point, setting that focal point as
6094        the active focal point in the observer's current domain, or in
6095        the specified domain if one is specified.
6096
6097        A `JournalParseError` is raised if the current/specified domain
6098        does not have plural focalization. If it doesn't have a focal
6099        point with that name, then one is created and positioned at the
6100        observer's current decision (which must be in the appropriate
6101        domain).
6102
6103        If `inCommon` is set to `True` (default is `False`) then the
6104        changes will be applied to the common context instead of the
6105        active context.
6106
6107        Note that this does *not* make the target domain active; use
6108        `recordDomainFocus` for that if you need to.
6109        """
6110        if inDomain is None:
6111            inDomain = self.context['domain']
6112
6113        if inCommon:
6114            ctx = self.getExploration().getCommonContext()
6115        else:
6116            ctx = self.getExploration().getActiveContext()
6117
6118        if ctx['focalization'].get('domain') != 'plural':
6119            raise JournalParseError(
6120                f"Domain {inDomain!r} does not exist or does not have"
6121                f" plural focalization, so we can't set a focal point"
6122                f" in it."
6123            )
6124
6125        focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {})
6126        if not isinstance(focalPointMap, dict):
6127            raise RuntimeError(
6128                f"Plural-focalized domain {inDomain!r} has"
6129                f" non-dictionary active"
6130                f" decisions:\n{repr(focalPointMap)}"
6131            )
6132
6133        if newFocalPoint not in focalPointMap:
6134            focalPointMap[newFocalPoint] = self.context['decision']
6135
6136        self.context['focus'] = newFocalPoint
6137        self.context['decision'] = focalPointMap[newFocalPoint]
6138
6139    def recordDomainUnfocus(
6140        self,
6141        domain: base.Domain,
6142        inCommon: bool = False
6143    ):
6144        """
6145        Records a domain losing focus. Does not raise an error if the
6146        target domain was not active (in that case, it doesn't need to
6147        do anything).
6148
6149        If `inCommon` is set to `True` (default is `False`) then the
6150        domain changes will be applied to the common context instead of
6151        the active context.
6152        """
6153        if inCommon:
6154            ctx = self.getExploration().getCommonContext()
6155        else:
6156            ctx = self.getExploration().getActiveContext()
6157
6158        try:
6159            ctx['activeDomains'].remove(domain)
6160        except KeyError:
6161            pass
6162
6163    def recordDomainFocus(
6164        self,
6165        domain: base.Domain,
6166        exclusive: bool = False,
6167        inCommon: bool = False
6168    ):
6169        """
6170        Records a domain gaining focus, activating that domain in the
6171        current focal context and setting it as the observer's current
6172        domain. If the domain named doesn't exist yet, it will be
6173        created first (with default focalization) and then focused.
6174
6175        If `exclusive` is set to `True` (default is `False`) then all
6176        other active domains will be deactivated.
6177
6178        If `inCommon` is set to `True` (default is `False`) then the
6179        domain changes will be applied to the common context instead of
6180        the active context.
6181        """
6182        if inCommon:
6183            ctx = self.getExploration().getCommonContext()
6184        else:
6185            ctx = self.getExploration().getActiveContext()
6186
6187        if exclusive:
6188            ctx['activeDomains'] = set()
6189
6190        if domain not in ctx['focalization']:
6191            self.recordNewDomain(domain, inCommon=inCommon)
6192        else:
6193            ctx['activeDomains'].add(domain)
6194
6195        self.context['domain'] = domain
6196
6197    def recordNewDomain(
6198        self,
6199        domain: base.Domain,
6200        focalization: base.DomainFocalization = "singular",
6201        inCommon: bool = False
6202    ):
6203        """
6204        Records a new domain, setting it up with the specified
6205        focalization. Sets that domain as an active domain and as the
6206        journal's current domain so that subsequent entries will create
6207        decisions in that domain. However, it does not activate any
6208        decisions within that domain.
6209
6210        Raises a `JournalParseError` if the specified domain already
6211        exists.
6212
6213        If `inCommon` is set to `True` (default is `False`) then the new
6214        domain will be made active in the common context instead of the
6215        active context.
6216        """
6217        if inCommon:
6218            ctx = self.getExploration().getCommonContext()
6219        else:
6220            ctx = self.getExploration().getActiveContext()
6221
6222        if domain in ctx['focalization']:
6223            raise JournalParseError(
6224                f"Cannot create domain {domain!r}: that domain already"
6225                f" exists."
6226            )
6227
6228        ctx['focalization'][domain] = focalization
6229        ctx['activeDecisions'][domain] = None
6230        ctx['activeDomains'].add(domain)
6231        self.context['domain'] = domain
6232
6233    def relative(
6234        self,
6235        where: Optional[base.AnyDecisionSpecifier] = None,
6236        transition: Optional[base.Transition] = None,
6237    ) -> None:
6238        """
6239        Enters 'relative mode' where the exploration ceases to add new
6240        steps but edits can still be performed on the current graph. This
6241        also changes the current decision/transition settings so that
6242        edits can be applied anywhere. It can accept 0, 1, or 2
6243        arguments. With 0 arguments, it simply enters relative mode but
6244        maintains the current position as the target decision and the
6245        last-taken or last-created transition as the target transition
6246        (note that that transition usually originates at a different
6247        decision). With 1 argument, it sets the target decision to the
6248        decision named, and sets the target transition to None. With 2
6249        arguments, it sets the target decision to the decision named, and
6250        the target transition to the transition named, which must
6251        originate at that target decision. If the first argument is None,
6252        the current decision is used.
6253
6254        If given the name of a decision which does not yet exist, it will
6255        create that decision in the current graph, disconnected from the
6256        rest of the graph. In that case, it is an error to also supply a
6257        transition to target (you can use other commands once in relative
6258        mode to build more transitions and decisions out from the
6259        newly-created decision).
6260
6261        When called in relative mode, it updates the current position
6262        and/or decision, or if called with no arguments, it exits
6263        relative mode. When exiting relative mode, the current decision
6264        is set back to the graph's current position, and the current
6265        transition is set to whatever it was before relative mode was
6266        entered.
6267
6268        Raises a `TypeError` if a transition is specified without
6269        specifying a decision. Raises a `ValueError` if given no
6270        arguments and the exploration does not have a current position.
6271        Also raises a `ValueError` if told to target a specific
6272        transition which does not exist.
6273
6274        TODO: Example here!
6275        """
6276        # TODO: Not this?
6277        if where is None:
6278            if transition is None and self.inRelativeMode:
6279                # If we're in relative mode, cancel it
6280                self.inRelativeMode = False
6281
6282                # Here we restore saved sate
6283                if self.storedContext is None:
6284                    raise RuntimeError(
6285                        "No stored context despite being in relative"
6286                        "mode."
6287                    )
6288                self.context = self.storedContext
6289                self.storedContext = None
6290
6291            else:
6292                # Enter or stay in relative mode and set up the current
6293                # decision/transition as the targets
6294
6295                # Ensure relative mode
6296                self.inRelativeMode = True
6297
6298                # Store state
6299                self.storedContext = self.context
6300                where = self.storedContext['decision']
6301                if where is None:
6302                    raise ValueError(
6303                        "Cannot enter relative mode at the current"
6304                        " position because there is no current"
6305                        " position."
6306                    )
6307
6308                self.context = observationContext(
6309                    context=self.storedContext['context'],
6310                    domain=self.storedContext['domain'],
6311                    focus=self.storedContext['focus'],
6312                    decision=where,
6313                    transition=(
6314                        None
6315                        if transition is None
6316                        else (where, transition)
6317                    )
6318                )
6319
6320        else: # we have at least a decision to target
6321            # If we're entering relative mode instead of just changing
6322            # focus, we need to set up the current transition if no
6323            # transition was specified.
6324            entering: Optional[
6325                Tuple[
6326                    base.ContextSpecifier,
6327                    base.Domain,
6328                    Optional[base.FocalPointName]
6329                ]
6330            ] = None
6331            if not self.inRelativeMode:
6332                # We'll be entering relative mode, so store state
6333                entering = (
6334                    self.context['context'],
6335                    self.context['domain'],
6336                    self.context['focus']
6337                )
6338                self.storedContext = self.context
6339                if transition is None:
6340                    oldTransitionPair = self.context['transition']
6341                    if oldTransitionPair is not None:
6342                        oldBase, oldTransition = oldTransitionPair
6343                        if oldBase == where:
6344                            transition = oldTransition
6345
6346            # Enter (or stay in) relative mode
6347            self.inRelativeMode = True
6348
6349            now = self.exploration.getSituation()
6350            whereID: Optional[base.DecisionID]
6351            whereSpec: Optional[base.DecisionSpecifier] = None
6352            if isinstance(where, str):
6353                where = self.parseFormat.parseDecisionSpecifier(where)
6354                # might turn it into a DecisionID
6355
6356            if isinstance(where, base.DecisionID):
6357                whereID = where
6358            elif isinstance(where, base.DecisionSpecifier):
6359                # Add in current zone + domain info if those things
6360                # aren't explicit
6361                if self.currentDecisionTarget() is not None:
6362                    where = base.spliceDecisionSpecifiers(
6363                        where,
6364                        self.decisionTargetSpecifier()
6365                    )
6366                elif where.domain is None:
6367                    # Splice in current domain if needed
6368                    where = base.DecisionSpecifier(
6369                        domain=self.context['domain'],
6370                        zone=where.zone,
6371                        name=where.name
6372                    )
6373                whereID = now.graph.getDecision(where)  # might be None
6374                whereSpec = where
6375            else:
6376                raise TypeError(f"Invalid decision specifier: {where!r}")
6377
6378            # Create a new decision if necessary
6379            if whereID is None:
6380                if transition is not None:
6381                    raise TypeError(
6382                        f"Cannot specify a target transition when"
6383                        f" entering relative mode at previously"
6384                        f" non-existent decision"
6385                        f" {now.graph.identityOf(where)}."
6386                    )
6387                assert whereSpec is not None
6388                whereID = now.graph.addDecision(
6389                    whereSpec.name,
6390                    domain=whereSpec.domain
6391                )
6392                if whereSpec.zone is not None:
6393                    now.graph.addDecisionToZone(whereID, whereSpec.zone)
6394
6395            # Create the new context if we're entering relative mode
6396            if entering is not None:
6397                self.context = observationContext(
6398                    context=entering[0],
6399                    domain=entering[1],
6400                    focus=entering[2],
6401                    decision=whereID,
6402                    transition=(
6403                        None
6404                        if transition is None
6405                        else (whereID, transition)
6406                    )
6407                )
6408
6409            # Target the specified decision
6410            self.context['decision'] = whereID
6411
6412            # Target the specified transition
6413            if transition is not None:
6414                self.context['transition'] = (whereID, transition)
6415                if now.graph.getDestination(where, transition) is None:
6416                    raise ValueError(
6417                        f"Cannot target transition {transition!r} at"
6418                        f" decision {now.graph.identityOf(where)}:"
6419                        f" there is no such transition."
6420                    )
6421            # otherwise leave self.context['transition'] alone
6422
6423
6424#--------------------#
6425# Shortcut Functions #
6426#--------------------#
6427
6428def convertJournal(
6429    journal: str,
6430    fmt: Optional[JournalParseFormat] = None
6431) -> core.DiscreteExploration:
6432    """
6433    Converts a journal in text format into a `core.DiscreteExploration`
6434    object, using a fresh `JournalObserver`. An optional `ParseFormat`
6435    may be specified if the journal doesn't follow the default parse
6436    format.
6437    """
6438    obs = JournalObserver(fmt)
6439    obs.observe(journal)
6440    return obs.getExploration()
JournalEntryType = typing.Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative']

One of the types of entries that can be present in a journal. These can be written out long form, or abbreviated using a single letter (see DEFAULT_FORMAT). Each journal line is either an entry or a continuation of a previous entry. The available types are:

  • 'P' / 'preference': Followed by a setting name and value, controls global preferences for journal processing.

  • '=' / 'alias': Followed by zero or more words and then a block of commands, this establishes an alias that can be used as a custom command. Within the command block, curly braces surrounding a word will be replaced by the argument in the same position that that word appears following the alias (for example, an alias defined using:

    = redDoor name [
      o {name}
        qb red
    ]
    

    could be referenced using:

    > redDoor door
    

    and that reference would be equivalent to:

    o door
      qb red
    

    To help aliases be more flexible, if '_' is referenced between curly braces (or '_' followed by an integer), it will be substituted with an underscore followed by a unique number (these numbers will count up with each such reference used by a specific JournalObserver object). References within each alias substitution which are suffixed with the same digit (or which are unsuffixed) will get the same value. So for example, an alias:

    = savePoint [
      o {_}
      x {_} {_1} {_2}
          gt toSavePoint
      a save
        At save
      t {_2}
    ]
    

    when deployed twice like this:

    > savePoint
    > savePoint
    

    might translate to:

    o _17
    x _17 _18 _19
        g savePoint
    a save
      At save
    t _19
    o _20
    x _20 _21 _22
        g savePoint
    a save
      At save
    t _22
    
  • '>' / 'custom': Re-uses the code from a previously-defined alias. This command type is followed by an alias name and then one argument for each parameter of the named alias (see above for examples).

  • '?' / 'DEBUG': Prints out debugging information when executed. See DebugAction for the possible argument values and doDebug for more information on what they mean.

  • 'S" / 'START': Names the starting decision (or zone::decision pair). Must appear first except in journal fragments.

  • 'x' / 'explore': Names a transition taken and the decision (or new-zone::decision) reached as a result, possibly with a name for the reciprocal transition which is created. Use 'zone' afterwards to swap around zones above level 0.

  • 'r' / 'return': Names a transition taken and decision returned to, connecting a transition which previously connected to an unexplored area back to a known decision instead. May also include a reciprocal name.

  • 'a' / 'action': Names an action taken at the current decision and may include effects and/or requirements. Use to declare a new action (including transforming what was thought to be a transition into an action). Use 'retrace' instead (preferably with the 'actionPart' part) to re-activate an existing action.

  • 't' / 'retrace': Names a transition taken, where the destination is already explored. Works for actoins as well when 'actionPart' is specified, but it will raise an error if the type of transition (normal vs. action) doesn't match thid specifier.

  • 'w' / 'wait': indicates a step of exploration where no transition is taken. You can use 'A' afterwards to apply effects in order to represent events that happen outside of player control. Use 'action' instead for player-initiated effects.

  • 'p' / 'warp': Names a new decision (or zone::decision) to be at, but without adding a transition there from the previous decision. If no zone name is provided but the destination is a novel decision, it will be placed into the same zones as the origin.

  • 'o' / 'observe': Names a transition observed from the current decision, or a transition plus destination if the destination is known, or a transition plus destination plus reciprocal if reciprocal information is also available. Observations don't create exploration steps.

  • 'E' / 'END': Names an ending which is reached from the current decision via a new automatically-named transition.

  • 'm' / 'mechanism': names a new mechanism at the current decision and puts it in a starting state.

  • 'q' / 'requirement': Specifies a requirement to apply to the most-recently-defined transition or its reciprocal.

  • 'e' / 'effect': Specifies a base.Consequence that gets added to the consequence for the currently-relevant transition (or its reciprocal or both if reciprocalPart or bothPart is used). The remainder of the line (and/or the next few lines) should be parsable using ParseFormat.parseConsequence, or if not, using ParseFormat.parseEffect for a single effect.

  • 'A' / 'apply': Specifies an effect to be immediately applied to the current state, relative to the most-recently-taken or -defined transition. If a 'transitionPart' or 'reciprocalPart' target specifier is included, the effect will also be recorded as an effect in the current active core.Consequence context for the most recent transition or reciprocal, but otherwise it will just be applied without being stored in the graph. Note that effects which are hidden until activated should have their 'hidden' property set to True, regardless of whether they're added to the graph before or after the transition they are associated with. Also, certain effects like 'bounce' cannot be applied retroactively.

  • 'g' / 'tag': Applies one or more tags to the current exploration step, or to the current decision if 'decisionPart' is specified, or to either the most-recently-taken transition or its reciprocal if 'transitionPart' or 'reciprocalPart' is specified. May also tag a zone by using 'zonePart'. Tags may have values associated with them; without a value provided the default value is the number 1.

  • 'n' / 'annotate': Like 'tag' but applies an annotation, which is just a piece of text attached to. Certain annotations will trigger checks of various exploration state when applied to a step, and will emit warnings if the checks fail.Step annotations which begin with: * 'at:' - checks that the current primary decision matches the decision identified by the rest of the annotation. * 'active:' - checks that a decision is currently in the active decision set. * 'has:' - checks that the player has a specific amount of a certain token (write 'tokenamount' after 'has:', as in "has: coins3"). Will fail if the player doesn't have that amount, including if they have more. * 'level:' - checks that the level of the named skill matches a specific level (write 'skill^level', as in "level: bossFight^3"). This does not allow you to check SkillCombination effective levels; just individual skill levels. * 'can:' - checks that a requirement (parsed using ParseFormat.parseRequirement) is satisfied. * 'state:' - checks that a mechanism is in a certain state (write 'mechanism:state', as in "level: doors:open"). * 'exists:' - checks that a decision exists.

  • 'c' / 'context': Specifies either 'commonContext' or the name of a specific focal context to activate. Focal contexts represent things like multiple characters or teams, and by default capabilities, tokens, and skills are all tied to a specific focal context. If the name given is anything other than the 'commonContext' value then that context will be swapped to active (and created as a blank context if necessary). TODO: THIS

  • 'd' / 'domain': Specifies a domain name, swapping to that domain as the current domain, and setting it as an active domain in the current core.FocalContext. This does not de-activate other domains, but the journal has a notion of a single 'current' domain that entries will be applied to. If no focal point has been specified and we swap into a plural-focalized domain, the alphabetically-first focal point within that domain will be selected, but focal points for each domain are remembered when swapping back. Use the 'notApplicable' value after a domain name to deactivate that domain. Any other value after a domain name must be one of the 'focalizeSingular', 'focalizePlural', or 'focalizeSpreading' values to indicate that the domain uses that type of focalization. These should only be used when a domain is created, you cannot change the focalization type of a domain after creation. If no focalization type is given along with a new domain name, singular focalization will be used for that domain. If no domain is specified before performing the first action of an exploration, the core.DEFAULT_DOMAIN with singular focalization will be set up. TODO: THIS

  • 'f' / 'focus': Specifies a core.FocalPointName for the specific focal point that should be acted on by subsequent journal entries in a plural-focalized domain. Focal points represent things like individual units in a game where you have multiple units to control.

    May also specify a domain followed by a focal point name to change the focal point in a domain other than the current domain.

  • 'z' / 'zone': Specifies a zone name and a level (via extra zonePart characters) that will replace the current zone at the given hierarchy level for the current decision. This is done using the core.DiscreteExploration.reZone method.

  • 'u' / 'unify': Specifies a decision with which the current decision will be unified (or two decisions that will be unified with each other), merging their transitions. The name of the merged decision is the name of the second decision specified (or the only decision specified when merging the current decision). Can instead target a transition or reciprocal to merge (which must be at the current decision), although the transition to merge with must either lead to the same destination or lead to an unknown destination (which will then be merged with the transition's destination). Any transitions between the two merged decisions will remain as actions at the new decision.

  • 'v' / 'obviate': Specifies a transition at the current decision and a decision that it links to and updates that information, without actually crossing the transition. The reciprocal transition must also be specified, although one will be created if it didn't already exist. If the reciprocal does already exist, it must lead to an unknown decision.

  • 'X' / 'extinguish': Deletes an transition at the current decision. If it leads to an unknown decision which is not otherwise connected to anything, this will also delete that decision (even if it already has tags or annotations or the like). Can also be used (with a decision target) to delete a decision, which will delete all transitions touching that decision. Note that usually, 'unify' is easier to manage than extinguish for manipulating decisions.

  • 'C' / 'complicate': Takes a transition between two confirmed decisions and adds a new confirmed decision in the middle of it. The old ends of the transition both connect to the new decision, and new names are given to their new reciprocals. Does not change the player's position.

  • '.' / 'status': Sets the exploration status of the current decision (argument should be a base.ExplorationStatus). Without an argument, sets the status to 'explored'. When 'unfinishedPart' is given as a target specifier (once or twice), this instead prevents the decision from being automatically marked as 'explored' when we leave it.

  • 'R' / 'revert': Reverts some or all of the current state to a previously saved state. Saving happens via the 'save' effect type, but reverting is an explicit action. The first argument names the save slot to revert to, while the rest are interpreted as the set of aspects to revert (see base.revertedState).

  • 'F' / 'fulfills': Specifies a requirement and a capability, and adds an equivalence to the current graph such that if that requirement is fulfilled, the specified capability is considered to be active. This allows for later discovery of one or more powers which allow traversal of previously-marked transitions whose true requirements were unknown when they were discovered.

  • '@' / 'relative': Specifies a decision to be treated as the 'current decision' without actually setting the position there. Use the marker alone or twice (default '@ @') to enter relative mode at the current decision (or to exit it). Until used to reverse this effect, all position-changing entries change this relative position value instead of the actual position in the graph, and updates are applied to the current graph without creating new exploration steps or applying any effects. Useful for doing things like noting information about far-away locations disclosed in a cutscene. Can target a transition at the current node by specifying 'transitionPart' or two arguments for a decision and transition. In that case, the specified transition is counted as the 'most-recent-transition' for entry purposes and the same relative mode is entered.

JournalTargetType = typing.Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart']

The different parts that an entry can target. The signifiers for these target types will be concatenated with a journal entry signifier in some cases. For example, by default 'g' as an entry type means 'tag', and 't' as a target type means 'transition'. So 'gt' as an entry type means 'tag transition' and applies the relevant tag to the most-recently-created transition instead of the most-recently-created decision. The targetSeparator character (default '@') is used to combine an entry type with a target type when the entry type is written without abbreviation. In that case, the target specifier may drop the suffix 'Part' (e.g., tag@transition in place of gt). The available target parts are each valid only for specific entry types. The target parts are:

  • 'decisionPart' - Use to specify that the entry applies to a decision when it would normally apply to something else.
  • 'transitionPart' - Use to specify that the entry applies to a transition instead of a decision.
  • 'reciprocalPart' - Use to specify that the entry applies to a reciprocal transition instead of a decision or the normal transition.
  • 'bothPart' - Use to specify that the entry applies to both of two possibilities, such as to a transition and its reciprocal.
  • 'zonePart' - Use for re-zoning to indicate the hierarchy level. May be repeated; each instance increases the hierarchy level by 1 starting from 0. In the long form to specify a hierarchy level, use the letter 'z' followed by an integer, as in 'z3' for level 3. Also used in the same way for tagging or annotating zones.
  • 'actionPart' - Use for the 'observe' or 'retrace' entries to specify that the observed/retraced transition is an action (i.e., its destination is the same as its source) rather than a real transition (whose destination would be a new, unknown node).
  • 'endingPart' - Use only for the 'observe' entry to specify that the observed transition goes to an ending rather than a normal decision.
  • 'unfinishedPart' - Use only for the 'status' entry (and use either once or twice in the short form) to specify that a decision should NOT be finalized when we leave it.

The entry types where a target specifier can be applied are:

  • 'requirement': By default these are applied to transitions, but the 'reciprocalPart' target can be used to apply to a reciprocal instead. Use bothPart to apply the same requirement to both the transition and its reciprocal.
  • 'effect': Same as 'requirement'.
  • 'apply': Same as 'effect' (and see above).
  • 'tag': Applies the tag to the specified target instead of the current exploration step. When targeting zones using 'zonePart', if there are multiple zones that apply at a certain hierarchy level we target the smallest one (breaking ties alphabetically by name). TODO: target the most-recently asserted one. The 'zonePart' may be repeated to specify a hierarchy level, as in 'gzz' for level 1 instead of level 0, and you may also use 'z' followed by an integer, as in 'gz3' for level 3.
  • 'annotation': Same as 'tag'.
  • 'unify': By default applies to a decision, but can be applied to a transition or reciprocal instead.
  • 'extinguish': By default applies to a transition and its reciprocal, but can be applied to just one or the other, or to a decision.
  • 'relative': Only 'transition' applies here and changes the most-recent-transition value when entering relative mode instead of just changing the current-decision value. Can be used within relative mode to pick out an existing transition as well.
  • 'zone': This is the main place where the 'zonePart' target type applies, and it can actually be applied as many times as you want. Each application makes the zone specified apply to a higher level in the hierarchy of zones, so that instead of swapping the level-0 zone using 'z', the level-1 zone can be changed using 'zz' or the level 2 zone using 'zzz', etc. In lieu of using multiple 'z's, you can also just write one 'z' followed by an integer for the level you want to use (e.g., z0 for a level-0 zone, or z1 for a level-1 zone). When using a long-form entry type, the target may be given as the string 'zone' in which case the level-1 zone is used. To use a different zone level with a long-form entry type, repeat the 'z' followed by an integer, or use multiple 'z's.
  • 'observe': Uses the 'actionPart' and 'endingPart' target types, and those are the only applicable target types. Applying actionPart turns the observed transition into an action; applying endingPart turns it into an transition to an ending.
  • 'retrace': Uses 'actionPart' or not to distinguish what kind of transition is being taken. Riases a JournalParseError if the type of edge (external vs. self destination) doesn't match this distinction.
  • 'status': The only place where 'unfinishedPart' target type applies. Using it once or twice signifies that the decision should NOT be marked as completely-explored when we leave it.
JournalInfoType = typing.Literal['on', 'off', 'domainFocalizationSingular', 'domainFocalizationPlural', 'domainFocalizationSpreading', 'commonContext', 'comment', 'unknownItem', 'notApplicable', 'exclusiveDomain', 'targetSeparator', 'reciprocalSeparator', 'transitionAtDecision', 'blockDelimiters']

Represents a part of the journal syntax which isn't an entry type but is used to mark something else. For example, the character denoting an unknown item. The available values are:

  • 'on' / 'off': Used to indicate on/off status for preferences.
  • 'domainFocalizationSingular' / 'domainFocalizationPlural' / 'domainFocalizationSpreading': Used as markers after a domain for the core.DomainFocalization values.
  • 'commonContext': Used with 'context' in place of a core.FocalContextName to indicate that we are targeting the common focal context.
  • 'comment': Indicates extraneous text that should be ignored by the journal parser. Note that tags and/or annotations should usually be used to apply comments that will be accessible when viewing the exploration object.
  • 'unknownItem': Used in place of an item name to indicate that although an item is known to exist, it's not yet know what that item is. Note that when journaling, you should make up names for items you pick up, even if you don't know what they do yet. This notation should only be used for items that you haven't picked up because they're inaccessible, and despite being apparent, you don't know what they are because they come in a container (e.g., you see a sealed chest, but you don't know what's in it).
  • 'notApplicable': Used in certain positions to indicate that something is missing entirely or otherwise that a piece of information normally supplied is unnecessary. For example, when used as the reciprocal name for a transition, this will cause the reciprocal transition to be deleted entirely, or when used before a domain name with the 'domain' entry type it deactivates that domain. TODO
  • 'exclusiveDomain': Used to indicate that a domain being activated should deactivate other domains, instead of being activated along with them.
  • 'targetSeparator': Used in long-form entry types to separate the entry type from a target specifier when a target is specified. Default is '@'. For example, a 'gt' entry (tag transition) would be expressed as 'tag@transition' in the long form.
  • 'reciprocalSeparator': Used to indicate, within a requirement or a tag set, a separation between requirements/tags to be applied to the forward direction and requirements/tags to be applied to the reverse direction. Not always applicable (e.g., actions have no reverse direction).
  • 'transitionAtDecision' Used to separate a decision name from a transition name when identifying a specific transition.
  • 'blockDelimiters' Two characters used to delimit the start and end of a block of entries. Used for things like edit effects.
JournalMarkerType = typing.Union[typing.Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative'], typing.Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], typing.Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], typing.Literal['on', 'off', 'domainFocalizationSingular', 'domainFocalizationPlural', 'domainFocalizationSpreading', 'commonContext', 'comment', 'unknownItem', 'notApplicable', 'exclusiveDomain', 'targetSeparator', 'reciprocalSeparator', 'transitionAtDecision', 'blockDelimiters']]

Any journal marker type.

JournalFormat = typing.Dict[typing.Union[typing.Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative'], typing.Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], typing.Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], typing.Literal['on', 'off', 'domainFocalizationSingular', 'domainFocalizationPlural', 'domainFocalizationSpreading', 'commonContext', 'comment', 'unknownItem', 'notApplicable', 'exclusiveDomain', 'targetSeparator', 'reciprocalSeparator', 'transitionAtDecision', 'blockDelimiters']], str]

A journal format is specified using a dictionary with keys that denote journal marker types and values which are one-to-several-character strings indicating the markup used for that entry/info type.

DEFAULT_FORMAT: Dict[Union[Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative'], Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], Literal['on', 'off', 'domainFocalizationSingular', 'domainFocalizationPlural', 'domainFocalizationSpreading', 'commonContext', 'comment', 'unknownItem', 'notApplicable', 'exclusiveDomain', 'targetSeparator', 'reciprocalSeparator', 'transitionAtDecision', 'blockDelimiters']], str] = {'preference': 'P', 'alias': '=', 'custom': '>', 'DEBUG': '?', 'START': 'S', 'explore': 'x', 'return': 'r', 'action': 'a', 'retrace': 't', 'wait': 'w', 'warp': 'p', 'observe': 'o', 'END': 'E', 'mechanism': 'm', 'requirement': 'q', 'effect': 'e', 'apply': 'A', 'tag': 'g', 'annotate': 'n', 'context': 'c', 'domain': 'd', 'focus': 'f', 'zone': 'z', 'unify': 'u', 'obviate': 'v', 'extinguish': 'X', 'complicate': 'C', 'status': '.', 'revert': 'R', 'fulfills': 'F', 'relative': '@', 'decisionPart': 'd', 'transitionPart': 't', 'reciprocalPart': 'r', 'bothPart': 'b', 'zonePart': 'z', 'actionPart': 'a', 'endingPart': 'E', 'unfinishedPart': '.', 'pending': '?', 'active': '.', 'unintended': '!', 'imposed': '>', 'consequence': '~', 'on': 'on', 'off': 'off', 'domainFocalizationSingular': 'singular', 'domainFocalizationPlural': 'plural', 'domainFocalizationSpreading': 'spreading', 'commonContext': '*', 'comment': '#', 'unknownItem': '?', 'notApplicable': '-', 'exclusiveDomain': '>', 'reciprocalSeparator': '/', 'targetSeparator': '@', 'transitionAtDecision': '%', 'blockDelimiters': '[]'}

The default JournalFormat dictionary.

DebugAction = typing.Literal['here', 'transition', 'destinations', 'steps', 'decisions', 'active', 'primary', 'saved', 'inventory', 'mechanisms', 'equivalences']

The different kinds of debugging commands.

class JournalParseFormat(exploration.parsing.ParseFormat):
 660class JournalParseFormat(parsing.ParseFormat):
 661    """
 662    A ParseFormat manages the mapping from markers to entry types and
 663    vice versa.
 664    """
 665    def __init__(
 666        self,
 667        formatDict: parsing.Format = parsing.DEFAULT_FORMAT,
 668        journalMarkers: JournalFormat = DEFAULT_FORMAT
 669    ):
 670        """
 671        Sets up the parsing format. Accepts base and/or journal format
 672        dictionaries, but they both have defaults (see `DEFAULT_FORMAT`
 673        and `parsing.DEFAULT_FORMAT`). Raises a `ValueError` unless the
 674        keys of the format dictionaries exactly match the required
 675        values (the `parsing.Lexeme` values for the base format and the
 676        `JournalMarkerType` values for the journal format).
 677        """
 678        super().__init__(formatDict)
 679        self.journalMarkers: JournalFormat = journalMarkers
 680
 681        # Build comment regular expression
 682        self.commentRE = re.compile(
 683            self.journalMarkers.get('comment', '#') + '.*$',
 684            flags=re.MULTILINE
 685        )
 686
 687        # Get block delimiters
 688        blockDelimiters = journalMarkers.get('blockDelimiters', '[]')
 689        if len(blockDelimiters) != 2:
 690            raise ValueError(
 691                f"Block delimiters must be a length-2 string containing"
 692                f" the start and end markers. Got: {blockDelimiters!r}."
 693            )
 694        blockStart = blockDelimiters[0]
 695        blockEnd = blockDelimiters[1]
 696        self.blockStart = blockStart
 697        self.blockEnd = blockEnd
 698
 699        # Add backslash for literal if it's an RE special char
 700        if blockStart in '[]()*.?^$&+\\':
 701            blockStart = '\\' + blockStart
 702        if blockEnd in '[]()*.?^$&+\\':
 703            blockEnd = '\\' + blockEnd
 704
 705        # Build block start and end regular expressions
 706        self.blockStartRE = re.compile(
 707            blockStart + r'\s*$',
 708            flags=re.MULTILINE
 709        )
 710        self.blockEndRE = re.compile(
 711            r'^\s*' + blockEnd,
 712            flags=re.MULTILINE
 713        )
 714
 715        # Check that journalMarkers doesn't have any extra keys
 716        markerTypes = (
 717            get_args(JournalEntryType)
 718          + get_args(base.DecisionType)
 719          + get_args(JournalTargetType)
 720          + get_args(JournalInfoType)
 721        )
 722        for key in journalMarkers:
 723            if key not in markerTypes:
 724                raise ValueError(
 725                    f"Format dict has key {key!r} which is not a"
 726                    f" recognized entry or info type."
 727                )
 728
 729        # Check completeness of formatDict
 730        for mtype in markerTypes:
 731            if mtype not in journalMarkers:
 732                raise ValueError(
 733                    f"Journal markers dict is missing an entry for"
 734                    f" marker type {mtype!r}."
 735                )
 736
 737        # Build reverse dictionaries from markers to entry types and
 738        # from markers to target types (no reverse needed for info
 739        # types).
 740        self.entryMap: Dict[str, JournalEntryType] = {}
 741        self.targetMap: Dict[str, JournalTargetType] = {}
 742        entryTypes = set(get_args(JournalEntryType))
 743        targetTypes = set(get_args(JournalTargetType))
 744
 745        # Check for duplicates and create reverse maps
 746        for name, marker in journalMarkers.items():
 747            if name in entryTypes:
 748                # Duplicates not allowed among entry types
 749                if marker in self.entryMap:
 750                    raise ValueError(
 751                        f"Format dict entry for {name!r} duplicates"
 752                        f" previous format dict entry for"
 753                        f" {self.entryMap[marker]!r}."
 754                    )
 755
 756                # Map markers to entry types
 757                self.entryMap[marker] = cast(JournalEntryType, name)
 758            elif name in targetTypes:
 759                # Duplicates not allowed among entry types
 760                if marker in self.targetMap:
 761                    raise ValueError(
 762                        f"Format dict entry for {name!r} duplicates"
 763                        f" previous format dict entry for"
 764                        f" {self.targetMap[marker]!r}."
 765                    )
 766
 767                # Map markers to entry types
 768                self.targetMap[marker] = cast(JournalTargetType, name)
 769
 770            # else ignore it since it's an info type
 771
 772    def markers(self) -> List[str]:
 773        """
 774        Returns the list of all entry-type markers (but not other kinds
 775        of markers), sorted from longest to shortest to help avoid
 776        ambiguities when matching.
 777        """
 778        entryTypes = get_args(JournalEntryType)
 779        return sorted(
 780            (
 781                m
 782                for (et, m) in self.journalMarkers.items()
 783                if et in entryTypes
 784            ),
 785            key=lambda m: -len(m)
 786        )
 787
 788    def markerFor(self, markerType: JournalMarkerType) -> str:
 789        """
 790        Returns the marker for the specified entry/info/effect/etc.
 791        type.
 792        """
 793        return self.journalMarkers[markerType]
 794
 795    def determineEntryType(self, entryBits: List[str]) -> Tuple[
 796        JournalEntryType,
 797        base.DecisionType,
 798        Union[None, JournalTargetType, Tuple[JournalTargetType, int]],
 799        List[str]
 800    ]:
 801        """
 802        Given a sequence of strings that specify a command, returns a
 803        tuple containing the entry type, decision type, target part, and
 804        list of arguments for that command. The default decision type is
 805        'active' but others can be specified with decision type
 806        modifiers. If no target type was included, the third entry of
 807        the return value will be `None`, and in the special case of
 808        zones, it will be an integer indicating the hierarchy level
 809        according to how many times the 'zonePart' target specifier was
 810        present, default 0.
 811
 812        For example:
 813
 814        >>> pf = JournalParseFormat()
 815        >>> pf.determineEntryType(['retrace', 'transition'])
 816        ('retrace', 'active', None, ['transition'])
 817        >>> pf.determineEntryType(['t', 'transition'])
 818        ('retrace', 'active', None, ['transition'])
 819        >>> pf.determineEntryType(['observe@action', 'open'])
 820        ('observe', 'active', 'actionPart', ['open'])
 821        >>> pf.determineEntryType(['oa', 'open'])
 822        ('observe', 'active', 'actionPart', ['open'])
 823        >>> pf.determineEntryType(['!explore', 'down', 'pit', 'up'])
 824        ('explore', 'unintended', None, ['down', 'pit', 'up'])
 825        >>> pf.determineEntryType(['imposed/explore', 'down', 'pit', 'up'])
 826        ('explore', 'imposed', None, ['down', 'pit', 'up'])
 827        >>> pf.determineEntryType(['~x', 'down', 'pit', 'up'])
 828        ('explore', 'consequence', None, ['down', 'pit', 'up'])
 829        >>> pf.determineEntryType(['>x', 'down', 'pit', 'up'])
 830        ('explore', 'imposed', None, ['down', 'pit', 'up'])
 831        >>> pf.determineEntryType(['gzz', 'tag'])
 832        ('tag', 'active', ('zonePart', 1), ['tag'])
 833        >>> pf.determineEntryType(['gz4', 'tag'])
 834        ('tag', 'active', ('zonePart', 4), ['tag'])
 835        >>> pf.determineEntryType(['zone@z2', 'ZoneName'])
 836        ('zone', 'active', ('zonePart', 2), ['ZoneName'])
 837        >>> pf.determineEntryType(['zzz', 'ZoneName'])
 838        ('zone', 'active', ('zonePart', 2), ['ZoneName'])
 839        """
 840        # Get entry specifier
 841        entrySpecifier = entryBits[0]
 842        entryArgs = entryBits[1:]
 843
 844        # Defaults
 845        entryType: Optional[JournalEntryType] = None
 846        entryTarget: Union[
 847            None,
 848            JournalTargetType,
 849            Tuple[JournalTargetType, int]
 850        ] = None
 851        entryDecisionType: base.DecisionType = 'active'
 852
 853        # Check for a decision type specifier and process+remove it
 854        for decisionType in get_args(base.DecisionType):
 855            marker = self.markerFor(decisionType)
 856            lm = len(marker)
 857            if (
 858                entrySpecifier.startswith(marker)
 859            and len(entrySpecifier) > lm
 860            ):
 861                entrySpecifier = entrySpecifier[lm:]
 862                entryDecisionType = decisionType
 863                break
 864            elif entrySpecifier.startswith(
 865                decisionType + self.markerFor('reciprocalSeparator')
 866            ):
 867                entrySpecifier = entrySpecifier[len(decisionType) + 1:]
 868                entryDecisionType = decisionType
 869                break
 870
 871        # Sets of valid types and targets
 872        validEntryTypes: Set[JournalEntryType] = set(
 873            get_args(JournalEntryType)
 874        )
 875        validEntryTargets: Set[JournalTargetType] = set(
 876            get_args(JournalTargetType)
 877        )
 878
 879        # Look for a long-form entry specifier with an @ sign separating
 880        # the entry type from the entry target
 881        targetMarker = self.markerFor('targetSeparator')
 882        if (
 883            targetMarker in entrySpecifier
 884        and not entrySpecifier.startswith(targetMarker)
 885            # Because the targetMarker is also a valid entry type!
 886        ):
 887            specifierBits = entrySpecifier.split(targetMarker)
 888            if len(specifierBits) != 2:
 889                raise JournalParseError(
 890                    f"When a long-form entry specifier contains a"
 891                    f" target separator, it must contain exactly one (to"
 892                    f" split the entry type from the entry target). We got"
 893                    f" {entrySpecifier!r}."
 894                )
 895            entryTypeGuess: str
 896            entryTargetGuess: Optional[str]
 897            entryTypeGuess, entryTargetGuess = specifierBits
 898            if entryTypeGuess not in validEntryTypes:
 899                raise JournalParseError(
 900                    f"Invalid long-form entry type: {entryType!r}"
 901                )
 902            else:
 903                entryType = cast(JournalEntryType, entryTypeGuess)
 904
 905            # Special logic for zone part
 906            handled = False
 907            if entryType in ('zone', 'tag', 'annotate'):
 908                handled = True
 909                if entryType == 'zone' and entryTargetGuess.isdigit():
 910                    entryTarget = ('zonePart', int(entryTargetGuess))
 911                elif entryTargetGuess == 'zone':
 912                    entryTarget = ('zonePart', 1 if entryType == 'zone' else 0)
 913                elif (
 914                    entryTargetGuess.startswith('z')
 915                and entryTargetGuess[1:].isdigit()
 916                ):
 917                    entryTarget = ('zonePart', int(entryTargetGuess[1:]))
 918                elif (
 919                    len(entryTargetGuess) > 0
 920                and set(entryTargetGuess) != {'z'}
 921                ):
 922                    if entryType == 'zone':
 923                        raise JournalParseError(
 924                            f"Invalid target specifier for"
 925                            f" zone entry:\n{entryTargetGuess}"
 926                        )
 927                    else:
 928                        handled = False
 929                else:
 930                    entryTarget = ('zonePart', len(entryTargetGuess))
 931
 932            if not handled:
 933                if entryTargetGuess + 'Part' in validEntryTargets:
 934                    entryTarget = cast(
 935                        JournalTargetType,
 936                        entryTargetGuess + 'Part'
 937                    )
 938                else:
 939                    origGuess = entryTargetGuess
 940                    entryTargetGuess = self.targetMap.get(
 941                        entryTargetGuess,
 942                        entryTargetGuess
 943                    )
 944                    if entryTargetGuess not in validEntryTargets:
 945                        raise JournalParseError(
 946                            f"Invalid long-form entry target:"
 947                            f" {origGuess!r}"
 948                        )
 949                    else:
 950                        entryTarget = cast(
 951                            JournalTargetType,
 952                            entryTargetGuess
 953                        )
 954
 955        elif entrySpecifier in validEntryTypes:
 956            # Might be a long-form specifier without a separator
 957            entryType = cast(JournalEntryType, entrySpecifier)
 958            entryTarget = None
 959            if entryType == 'zone':
 960                entryTarget = ('zonePart', 0)
 961
 962        else:  # parse a short-form entry specifier
 963            typeSpecifier = entrySpecifier[0]
 964            if typeSpecifier not in self.entryMap:
 965                raise JournalParseError(
 966                    f"Entry does not begin with a recognized entry"
 967                    f" marker:\n{entryBits}"
 968                )
 969            entryType = self.entryMap[typeSpecifier]
 970
 971            # Figure out the entry target from second+ character(s)
 972            targetSpecifiers = entrySpecifier[1:]
 973            specifiersSet = set(targetSpecifiers)
 974            if entryType == 'zone':
 975                if targetSpecifiers.isdigit():
 976                    entryTarget = ('zonePart', int(targetSpecifiers))
 977                elif (
 978                    len(specifiersSet) > 0
 979                and specifiersSet != {self.journalMarkers['zonePart']}
 980                ):
 981                    raise JournalParseError(
 982                        f"Invalid target specifier for zone:\n{entryBits}"
 983                    )
 984                else:
 985                    entryTarget = ('zonePart', len(targetSpecifiers))
 986            elif entryType == 'status':
 987                if len(targetSpecifiers) > 0:
 988                    if any(
 989                        x != self.journalMarkers['unfinishedPart']
 990                        for x in targetSpecifiers
 991                    ):
 992                        raise JournalParseError(
 993                            f"Invalid target specifier for"
 994                            f" status:\n{entryBits}"
 995                        )
 996                    entryTarget = 'unfinishedPart'
 997                else:
 998                    entryTarget = None
 999            elif len(targetSpecifiers) > 0:
1000                if (
1001                    targetSpecifiers[1:].isdigit()
1002                and targetSpecifiers[0] in self.targetMap
1003                ):
1004                    entryTarget = (
1005                        self.targetMap[targetSpecifiers[0]],
1006                        int(targetSpecifiers[1:])
1007                    )
1008                elif len(specifiersSet) > 1:
1009                    raise JournalParseError(
1010                        f"Entry has too many target specifiers:\n{entryBits}"
1011                    )
1012                else:
1013                    specifier = list(specifiersSet)[0]
1014                    copies = len(targetSpecifiers)
1015                    if specifier not in self.targetMap:
1016                        raise JournalParseError(
1017                            f"Unrecognized target specifier in:\n{entryBits}"
1018                        )
1019                    entryTarget = self.targetMap[specifier]
1020                    if copies > 1:
1021                        entryTarget = (entryTarget, copies - 1)
1022        # else entryTarget remains None
1023
1024        return (entryType, entryDecisionType, entryTarget, entryArgs)
1025
1026    def argsString(self, pieces: List[str]) -> str:
1027        """
1028        Recombines pieces of a journal argument (such as those produced
1029        by `unparseEffect`) into a single string. When there are
1030        multi-line or space-containing pieces, this adds block start/end
1031        delimiters and indents the piece if it's multi-line.
1032        """
1033        result = ''
1034        for piece in pieces:
1035            if '\n' in piece:
1036                result += (
1037                    f" {self.blockStart}\n"
1038                    f"{textwrap.indent(piece, '  ')}"
1039                    f"{self.blockEnd}"
1040                )
1041            elif ' ' in piece:
1042                result += f" {self.blockStart}{piece}{self.blockEnd}"
1043            else:
1044                result += ' ' + piece
1045
1046        return result[1:]  # chop off extra initial space
1047
1048    def removeComments(self, text: str) -> str:
1049        """
1050        Given one or more lines from a journal, removes all comments from
1051        it/them. Any '#' and any following characters through the end of
1052        a line counts as a comment.
1053
1054        Returns the text without comments.
1055
1056        Example:
1057
1058        >>> pf = JournalParseFormat()
1059        >>> pf.removeComments('abc # 123')
1060        'abc '
1061        >>> pf.removeComments('''\\
1062        ... line one # comment
1063        ... line two # comment
1064        ... line three
1065        ... line four # comment
1066        ... ''')
1067        'line one \\nline two \\nline three\\nline four \\n'
1068        """
1069        return self.commentRE.sub('', text)
1070
1071    def findBlockEnd(self, string: str, startIndex: int) -> int:
1072        """
1073        Given a string and a start index where a block open delimiter
1074        is, returns the index within the string of the matching block
1075        closing delimiter.
1076
1077        There are two possibilities: either both the opening and closing
1078        delimiter appear on the same line, or the block start appears at
1079        the end of a line (modulo whitespce) and the block end appears
1080        at the beginning of a line (modulo whitespace). Any other
1081        configuration is invalid and may lead to a `JournalParseError`.
1082
1083        Note that blocks may be nested within each other, including
1084        nesting single-line blocks in a multi-line block. It's also
1085        possible for several single-line blocks to appear on the same
1086        line.
1087
1088        Examples:
1089
1090        >>> pf = JournalParseFormat()
1091        >>> pf.findBlockEnd('[ A ]', 0)
1092        4
1093        >>> pf.findBlockEnd('[ A ] [ B ]', 0)
1094        4
1095        >>> pf.findBlockEnd('[ A ] [ B ]', 6)
1096        10
1097        >>> pf.findBlockEnd('[ A [ B ] ]', 0)
1098        10
1099        >>> pf.findBlockEnd('[ A [ B ] ]', 4)
1100        8
1101        >>> pf.findBlockEnd('[ [ B ]', 0)
1102        Traceback (most recent call last):
1103        ...
1104        exploration.journal.JournalParseError...
1105        >>> pf.findBlockEnd('[\\nABC\\n]', 0)
1106        6
1107        >>> pf.findBlockEnd('[\\nABC]', 0)  # End marker must start line
1108        Traceback (most recent call last):
1109        ...
1110        exploration.journal.JournalParseError...
1111        >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n  ]', 0)
1112        19
1113        >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n  ]', 9)
1114        15
1115        >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n  ]', 0)
1116        19
1117        >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n  ]', 9)
1118        15
1119        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 0)
1120        24
1121        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 11)
1122        22
1123        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 16)
1124        18
1125        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H \\n  ]\\n]', 16)
1126        Traceback (most recent call last):
1127        ...
1128        exploration.journal.JournalParseError...
1129        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n\\n]', 0)
1130        Traceback (most recent call last):
1131        ...
1132        exploration.journal.JournalParseError...
1133        """
1134        # Find end of the line that the block opens on
1135        try:
1136            endOfLine = string.index('\n', startIndex)
1137        except ValueError:
1138            endOfLine = len(string)
1139
1140        # Determine if this is a single-line or multi-line block based
1141        # on the presence of *anything* after the opening delimiter
1142        restOfLine = string[startIndex + 1:endOfLine]
1143        if restOfLine.strip() != '':  # A single-line block
1144            level = 1
1145            for restIndex, char in enumerate(restOfLine):
1146                if char == self.blockEnd:
1147                    level -= 1
1148                    if level <= 0:
1149                        break
1150                elif char == self.blockStart:
1151                    level += 1
1152
1153            if level == 0:
1154                return startIndex + 1 + restIndex
1155            else:
1156                raise JournalParseError(
1157                    f"Got to end of line in single-line block without"
1158                    f" finding the matching end-of-block marker."
1159                    f" Remainder of line is:\n  {restOfLine!r}"
1160                )
1161
1162        else:  # It's a multi-line block
1163            level = 1
1164            index = startIndex + 1
1165            while level > 0 and index < len(string):
1166                nextStart = self.blockStartRE.search(string, index)
1167                nextEnd = self.blockEndRE.search(string, index)
1168                if nextEnd is None:
1169                    break  # no end in sight; level won't be 0
1170                elif (
1171                    nextStart is None
1172                 or nextStart.start() > nextEnd.start()
1173                ):
1174                    index = nextEnd.end()
1175                    level -= 1
1176                    if level <= 0:
1177                        break
1178                else:  # They cannot be equal
1179                    index = nextStart.end()
1180                    level += 1
1181
1182            if level == 0:
1183                if nextEnd is None:
1184                    raise RuntimeError(
1185                        "Parsing got to level 0 with no valid end"
1186                        " match."
1187                    )
1188                return nextEnd.end() - 1
1189            else:
1190                raise JournalParseError(
1191                    f"Got to the end of the entire string and didn't"
1192                    f" find a matching end-of-block marker. Started at"
1193                    f" index {startIndex}."
1194                )

A ParseFormat manages the mapping from markers to entry types and vice versa.

JournalParseFormat( formatDict: Dict[exploration.parsing.Lexeme, str] = {<Lexeme.domainSeparator: 1>: '//', <Lexeme.zoneSeparator: 2>: '::', <Lexeme.partSeparator: 3>: '%%', <Lexeme.stateOn: 4>: '=on', <Lexeme.stateOff: 5>: '=off', <Lexeme.tokenCount: 6>: '*', <Lexeme.effectCharges: 7>: '=', <Lexeme.sepOrDelay: 8>: ',', <Lexeme.consequenceSeparator: 9>: ';', <Lexeme.inCommon: 10>: '+c', <Lexeme.isHidden: 11>: '+h', <Lexeme.skillLevel: 12>: '^', <Lexeme.wigglyLine: 13>: '~', <Lexeme.withDetails: 14>: '%', <Lexeme.reciprocalSeparator: 15>: '/', <Lexeme.mechanismSeparator: 16>: ':', <Lexeme.openCurly: 17>: '{', <Lexeme.closeCurly: 18>: '}', <Lexeme.openParen: 19>: '(', <Lexeme.closeParen: 20>: ')', <Lexeme.angleLeft: 21>: '<', <Lexeme.angleRight: 22>: '>', <Lexeme.doubleQuestionmark: 23>: '??', <Lexeme.ampersand: 24>: '&', <Lexeme.orBar: 25>: '|', <Lexeme.notMarker: 26>: '!'}, journalMarkers: Dict[Union[Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative'], Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], Literal['on', 'off', 'domainFocalizationSingular', 'domainFocalizationPlural', 'domainFocalizationSpreading', 'commonContext', 'comment', 'unknownItem', 'notApplicable', 'exclusiveDomain', 'targetSeparator', 'reciprocalSeparator', 'transitionAtDecision', 'blockDelimiters']], str] = {'preference': 'P', 'alias': '=', 'custom': '>', 'DEBUG': '?', 'START': 'S', 'explore': 'x', 'return': 'r', 'action': 'a', 'retrace': 't', 'wait': 'w', 'warp': 'p', 'observe': 'o', 'END': 'E', 'mechanism': 'm', 'requirement': 'q', 'effect': 'e', 'apply': 'A', 'tag': 'g', 'annotate': 'n', 'context': 'c', 'domain': 'd', 'focus': 'f', 'zone': 'z', 'unify': 'u', 'obviate': 'v', 'extinguish': 'X', 'complicate': 'C', 'status': '.', 'revert': 'R', 'fulfills': 'F', 'relative': '@', 'decisionPart': 'd', 'transitionPart': 't', 'reciprocalPart': 'r', 'bothPart': 'b', 'zonePart': 'z', 'actionPart': 'a', 'endingPart': 'E', 'unfinishedPart': '.', 'pending': '?', 'active': '.', 'unintended': '!', 'imposed': '>', 'consequence': '~', 'on': 'on', 'off': 'off', 'domainFocalizationSingular': 'singular', 'domainFocalizationPlural': 'plural', 'domainFocalizationSpreading': 'spreading', 'commonContext': '*', 'comment': '#', 'unknownItem': '?', 'notApplicable': '-', 'exclusiveDomain': '>', 'reciprocalSeparator': '/', 'targetSeparator': '@', 'transitionAtDecision': '%', 'blockDelimiters': '[]'})
665    def __init__(
666        self,
667        formatDict: parsing.Format = parsing.DEFAULT_FORMAT,
668        journalMarkers: JournalFormat = DEFAULT_FORMAT
669    ):
670        """
671        Sets up the parsing format. Accepts base and/or journal format
672        dictionaries, but they both have defaults (see `DEFAULT_FORMAT`
673        and `parsing.DEFAULT_FORMAT`). Raises a `ValueError` unless the
674        keys of the format dictionaries exactly match the required
675        values (the `parsing.Lexeme` values for the base format and the
676        `JournalMarkerType` values for the journal format).
677        """
678        super().__init__(formatDict)
679        self.journalMarkers: JournalFormat = journalMarkers
680
681        # Build comment regular expression
682        self.commentRE = re.compile(
683            self.journalMarkers.get('comment', '#') + '.*$',
684            flags=re.MULTILINE
685        )
686
687        # Get block delimiters
688        blockDelimiters = journalMarkers.get('blockDelimiters', '[]')
689        if len(blockDelimiters) != 2:
690            raise ValueError(
691                f"Block delimiters must be a length-2 string containing"
692                f" the start and end markers. Got: {blockDelimiters!r}."
693            )
694        blockStart = blockDelimiters[0]
695        blockEnd = blockDelimiters[1]
696        self.blockStart = blockStart
697        self.blockEnd = blockEnd
698
699        # Add backslash for literal if it's an RE special char
700        if blockStart in '[]()*.?^$&+\\':
701            blockStart = '\\' + blockStart
702        if blockEnd in '[]()*.?^$&+\\':
703            blockEnd = '\\' + blockEnd
704
705        # Build block start and end regular expressions
706        self.blockStartRE = re.compile(
707            blockStart + r'\s*$',
708            flags=re.MULTILINE
709        )
710        self.blockEndRE = re.compile(
711            r'^\s*' + blockEnd,
712            flags=re.MULTILINE
713        )
714
715        # Check that journalMarkers doesn't have any extra keys
716        markerTypes = (
717            get_args(JournalEntryType)
718          + get_args(base.DecisionType)
719          + get_args(JournalTargetType)
720          + get_args(JournalInfoType)
721        )
722        for key in journalMarkers:
723            if key not in markerTypes:
724                raise ValueError(
725                    f"Format dict has key {key!r} which is not a"
726                    f" recognized entry or info type."
727                )
728
729        # Check completeness of formatDict
730        for mtype in markerTypes:
731            if mtype not in journalMarkers:
732                raise ValueError(
733                    f"Journal markers dict is missing an entry for"
734                    f" marker type {mtype!r}."
735                )
736
737        # Build reverse dictionaries from markers to entry types and
738        # from markers to target types (no reverse needed for info
739        # types).
740        self.entryMap: Dict[str, JournalEntryType] = {}
741        self.targetMap: Dict[str, JournalTargetType] = {}
742        entryTypes = set(get_args(JournalEntryType))
743        targetTypes = set(get_args(JournalTargetType))
744
745        # Check for duplicates and create reverse maps
746        for name, marker in journalMarkers.items():
747            if name in entryTypes:
748                # Duplicates not allowed among entry types
749                if marker in self.entryMap:
750                    raise ValueError(
751                        f"Format dict entry for {name!r} duplicates"
752                        f" previous format dict entry for"
753                        f" {self.entryMap[marker]!r}."
754                    )
755
756                # Map markers to entry types
757                self.entryMap[marker] = cast(JournalEntryType, name)
758            elif name in targetTypes:
759                # Duplicates not allowed among entry types
760                if marker in self.targetMap:
761                    raise ValueError(
762                        f"Format dict entry for {name!r} duplicates"
763                        f" previous format dict entry for"
764                        f" {self.targetMap[marker]!r}."
765                    )
766
767                # Map markers to entry types
768                self.targetMap[marker] = cast(JournalTargetType, name)
769
770            # else ignore it since it's an info type

Sets up the parsing format. Accepts base and/or journal format dictionaries, but they both have defaults (see DEFAULT_FORMAT and parsing.DEFAULT_FORMAT). Raises a ValueError unless the keys of the format dictionaries exactly match the required values (the parsing.Lexeme values for the base format and the JournalMarkerType values for the journal format).

journalMarkers: Dict[Union[Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative'], Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], Literal['on', 'off', 'domainFocalizationSingular', 'domainFocalizationPlural', 'domainFocalizationSpreading', 'commonContext', 'comment', 'unknownItem', 'notApplicable', 'exclusiveDomain', 'targetSeparator', 'reciprocalSeparator', 'transitionAtDecision', 'blockDelimiters']], str]
commentRE
blockStart
blockEnd
blockStartRE
blockEndRE
entryMap: Dict[str, Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative']]
targetMap: Dict[str, Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart']]
def markers(self) -> List[str]:
772    def markers(self) -> List[str]:
773        """
774        Returns the list of all entry-type markers (but not other kinds
775        of markers), sorted from longest to shortest to help avoid
776        ambiguities when matching.
777        """
778        entryTypes = get_args(JournalEntryType)
779        return sorted(
780            (
781                m
782                for (et, m) in self.journalMarkers.items()
783                if et in entryTypes
784            ),
785            key=lambda m: -len(m)
786        )

Returns the list of all entry-type markers (but not other kinds of markers), sorted from longest to shortest to help avoid ambiguities when matching.

def markerFor( self, markerType: Union[Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative'], Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], Literal['on', 'off', 'domainFocalizationSingular', 'domainFocalizationPlural', 'domainFocalizationSpreading', 'commonContext', 'comment', 'unknownItem', 'notApplicable', 'exclusiveDomain', 'targetSeparator', 'reciprocalSeparator', 'transitionAtDecision', 'blockDelimiters']]) -> str:
788    def markerFor(self, markerType: JournalMarkerType) -> str:
789        """
790        Returns the marker for the specified entry/info/effect/etc.
791        type.
792        """
793        return self.journalMarkers[markerType]

Returns the marker for the specified entry/info/effect/etc. type.

def determineEntryType( self, entryBits: List[str]) -> Tuple[Literal['preference', 'alias', 'custom', 'DEBUG', 'START', 'explore', 'return', 'action', 'retrace', 'warp', 'wait', 'observe', 'END', 'mechanism', 'requirement', 'effect', 'apply', 'tag', 'annotate', 'context', 'domain', 'focus', 'zone', 'unify', 'obviate', 'extinguish', 'complicate', 'status', 'revert', 'fulfills', 'relative'], Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], Union[NoneType, Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], Tuple[Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], int]], List[str]]:
 795    def determineEntryType(self, entryBits: List[str]) -> Tuple[
 796        JournalEntryType,
 797        base.DecisionType,
 798        Union[None, JournalTargetType, Tuple[JournalTargetType, int]],
 799        List[str]
 800    ]:
 801        """
 802        Given a sequence of strings that specify a command, returns a
 803        tuple containing the entry type, decision type, target part, and
 804        list of arguments for that command. The default decision type is
 805        'active' but others can be specified with decision type
 806        modifiers. If no target type was included, the third entry of
 807        the return value will be `None`, and in the special case of
 808        zones, it will be an integer indicating the hierarchy level
 809        according to how many times the 'zonePart' target specifier was
 810        present, default 0.
 811
 812        For example:
 813
 814        >>> pf = JournalParseFormat()
 815        >>> pf.determineEntryType(['retrace', 'transition'])
 816        ('retrace', 'active', None, ['transition'])
 817        >>> pf.determineEntryType(['t', 'transition'])
 818        ('retrace', 'active', None, ['transition'])
 819        >>> pf.determineEntryType(['observe@action', 'open'])
 820        ('observe', 'active', 'actionPart', ['open'])
 821        >>> pf.determineEntryType(['oa', 'open'])
 822        ('observe', 'active', 'actionPart', ['open'])
 823        >>> pf.determineEntryType(['!explore', 'down', 'pit', 'up'])
 824        ('explore', 'unintended', None, ['down', 'pit', 'up'])
 825        >>> pf.determineEntryType(['imposed/explore', 'down', 'pit', 'up'])
 826        ('explore', 'imposed', None, ['down', 'pit', 'up'])
 827        >>> pf.determineEntryType(['~x', 'down', 'pit', 'up'])
 828        ('explore', 'consequence', None, ['down', 'pit', 'up'])
 829        >>> pf.determineEntryType(['>x', 'down', 'pit', 'up'])
 830        ('explore', 'imposed', None, ['down', 'pit', 'up'])
 831        >>> pf.determineEntryType(['gzz', 'tag'])
 832        ('tag', 'active', ('zonePart', 1), ['tag'])
 833        >>> pf.determineEntryType(['gz4', 'tag'])
 834        ('tag', 'active', ('zonePart', 4), ['tag'])
 835        >>> pf.determineEntryType(['zone@z2', 'ZoneName'])
 836        ('zone', 'active', ('zonePart', 2), ['ZoneName'])
 837        >>> pf.determineEntryType(['zzz', 'ZoneName'])
 838        ('zone', 'active', ('zonePart', 2), ['ZoneName'])
 839        """
 840        # Get entry specifier
 841        entrySpecifier = entryBits[0]
 842        entryArgs = entryBits[1:]
 843
 844        # Defaults
 845        entryType: Optional[JournalEntryType] = None
 846        entryTarget: Union[
 847            None,
 848            JournalTargetType,
 849            Tuple[JournalTargetType, int]
 850        ] = None
 851        entryDecisionType: base.DecisionType = 'active'
 852
 853        # Check for a decision type specifier and process+remove it
 854        for decisionType in get_args(base.DecisionType):
 855            marker = self.markerFor(decisionType)
 856            lm = len(marker)
 857            if (
 858                entrySpecifier.startswith(marker)
 859            and len(entrySpecifier) > lm
 860            ):
 861                entrySpecifier = entrySpecifier[lm:]
 862                entryDecisionType = decisionType
 863                break
 864            elif entrySpecifier.startswith(
 865                decisionType + self.markerFor('reciprocalSeparator')
 866            ):
 867                entrySpecifier = entrySpecifier[len(decisionType) + 1:]
 868                entryDecisionType = decisionType
 869                break
 870
 871        # Sets of valid types and targets
 872        validEntryTypes: Set[JournalEntryType] = set(
 873            get_args(JournalEntryType)
 874        )
 875        validEntryTargets: Set[JournalTargetType] = set(
 876            get_args(JournalTargetType)
 877        )
 878
 879        # Look for a long-form entry specifier with an @ sign separating
 880        # the entry type from the entry target
 881        targetMarker = self.markerFor('targetSeparator')
 882        if (
 883            targetMarker in entrySpecifier
 884        and not entrySpecifier.startswith(targetMarker)
 885            # Because the targetMarker is also a valid entry type!
 886        ):
 887            specifierBits = entrySpecifier.split(targetMarker)
 888            if len(specifierBits) != 2:
 889                raise JournalParseError(
 890                    f"When a long-form entry specifier contains a"
 891                    f" target separator, it must contain exactly one (to"
 892                    f" split the entry type from the entry target). We got"
 893                    f" {entrySpecifier!r}."
 894                )
 895            entryTypeGuess: str
 896            entryTargetGuess: Optional[str]
 897            entryTypeGuess, entryTargetGuess = specifierBits
 898            if entryTypeGuess not in validEntryTypes:
 899                raise JournalParseError(
 900                    f"Invalid long-form entry type: {entryType!r}"
 901                )
 902            else:
 903                entryType = cast(JournalEntryType, entryTypeGuess)
 904
 905            # Special logic for zone part
 906            handled = False
 907            if entryType in ('zone', 'tag', 'annotate'):
 908                handled = True
 909                if entryType == 'zone' and entryTargetGuess.isdigit():
 910                    entryTarget = ('zonePart', int(entryTargetGuess))
 911                elif entryTargetGuess == 'zone':
 912                    entryTarget = ('zonePart', 1 if entryType == 'zone' else 0)
 913                elif (
 914                    entryTargetGuess.startswith('z')
 915                and entryTargetGuess[1:].isdigit()
 916                ):
 917                    entryTarget = ('zonePart', int(entryTargetGuess[1:]))
 918                elif (
 919                    len(entryTargetGuess) > 0
 920                and set(entryTargetGuess) != {'z'}
 921                ):
 922                    if entryType == 'zone':
 923                        raise JournalParseError(
 924                            f"Invalid target specifier for"
 925                            f" zone entry:\n{entryTargetGuess}"
 926                        )
 927                    else:
 928                        handled = False
 929                else:
 930                    entryTarget = ('zonePart', len(entryTargetGuess))
 931
 932            if not handled:
 933                if entryTargetGuess + 'Part' in validEntryTargets:
 934                    entryTarget = cast(
 935                        JournalTargetType,
 936                        entryTargetGuess + 'Part'
 937                    )
 938                else:
 939                    origGuess = entryTargetGuess
 940                    entryTargetGuess = self.targetMap.get(
 941                        entryTargetGuess,
 942                        entryTargetGuess
 943                    )
 944                    if entryTargetGuess not in validEntryTargets:
 945                        raise JournalParseError(
 946                            f"Invalid long-form entry target:"
 947                            f" {origGuess!r}"
 948                        )
 949                    else:
 950                        entryTarget = cast(
 951                            JournalTargetType,
 952                            entryTargetGuess
 953                        )
 954
 955        elif entrySpecifier in validEntryTypes:
 956            # Might be a long-form specifier without a separator
 957            entryType = cast(JournalEntryType, entrySpecifier)
 958            entryTarget = None
 959            if entryType == 'zone':
 960                entryTarget = ('zonePart', 0)
 961
 962        else:  # parse a short-form entry specifier
 963            typeSpecifier = entrySpecifier[0]
 964            if typeSpecifier not in self.entryMap:
 965                raise JournalParseError(
 966                    f"Entry does not begin with a recognized entry"
 967                    f" marker:\n{entryBits}"
 968                )
 969            entryType = self.entryMap[typeSpecifier]
 970
 971            # Figure out the entry target from second+ character(s)
 972            targetSpecifiers = entrySpecifier[1:]
 973            specifiersSet = set(targetSpecifiers)
 974            if entryType == 'zone':
 975                if targetSpecifiers.isdigit():
 976                    entryTarget = ('zonePart', int(targetSpecifiers))
 977                elif (
 978                    len(specifiersSet) > 0
 979                and specifiersSet != {self.journalMarkers['zonePart']}
 980                ):
 981                    raise JournalParseError(
 982                        f"Invalid target specifier for zone:\n{entryBits}"
 983                    )
 984                else:
 985                    entryTarget = ('zonePart', len(targetSpecifiers))
 986            elif entryType == 'status':
 987                if len(targetSpecifiers) > 0:
 988                    if any(
 989                        x != self.journalMarkers['unfinishedPart']
 990                        for x in targetSpecifiers
 991                    ):
 992                        raise JournalParseError(
 993                            f"Invalid target specifier for"
 994                            f" status:\n{entryBits}"
 995                        )
 996                    entryTarget = 'unfinishedPart'
 997                else:
 998                    entryTarget = None
 999            elif len(targetSpecifiers) > 0:
1000                if (
1001                    targetSpecifiers[1:].isdigit()
1002                and targetSpecifiers[0] in self.targetMap
1003                ):
1004                    entryTarget = (
1005                        self.targetMap[targetSpecifiers[0]],
1006                        int(targetSpecifiers[1:])
1007                    )
1008                elif len(specifiersSet) > 1:
1009                    raise JournalParseError(
1010                        f"Entry has too many target specifiers:\n{entryBits}"
1011                    )
1012                else:
1013                    specifier = list(specifiersSet)[0]
1014                    copies = len(targetSpecifiers)
1015                    if specifier not in self.targetMap:
1016                        raise JournalParseError(
1017                            f"Unrecognized target specifier in:\n{entryBits}"
1018                        )
1019                    entryTarget = self.targetMap[specifier]
1020                    if copies > 1:
1021                        entryTarget = (entryTarget, copies - 1)
1022        # else entryTarget remains None
1023
1024        return (entryType, entryDecisionType, entryTarget, entryArgs)

Given a sequence of strings that specify a command, returns a tuple containing the entry type, decision type, target part, and list of arguments for that command. The default decision type is 'active' but others can be specified with decision type modifiers. If no target type was included, the third entry of the return value will be None, and in the special case of zones, it will be an integer indicating the hierarchy level according to how many times the 'zonePart' target specifier was present, default 0.

For example:

>>> pf = JournalParseFormat()
>>> pf.determineEntryType(['retrace', 'transition'])
('retrace', 'active', None, ['transition'])
>>> pf.determineEntryType(['t', 'transition'])
('retrace', 'active', None, ['transition'])
>>> pf.determineEntryType(['observe@action', 'open'])
('observe', 'active', 'actionPart', ['open'])
>>> pf.determineEntryType(['oa', 'open'])
('observe', 'active', 'actionPart', ['open'])
>>> pf.determineEntryType(['!explore', 'down', 'pit', 'up'])
('explore', 'unintended', None, ['down', 'pit', 'up'])
>>> pf.determineEntryType(['imposed/explore', 'down', 'pit', 'up'])
('explore', 'imposed', None, ['down', 'pit', 'up'])
>>> pf.determineEntryType(['~x', 'down', 'pit', 'up'])
('explore', 'consequence', None, ['down', 'pit', 'up'])
>>> pf.determineEntryType(['>x', 'down', 'pit', 'up'])
('explore', 'imposed', None, ['down', 'pit', 'up'])
>>> pf.determineEntryType(['gzz', 'tag'])
('tag', 'active', ('zonePart', 1), ['tag'])
>>> pf.determineEntryType(['gz4', 'tag'])
('tag', 'active', ('zonePart', 4), ['tag'])
>>> pf.determineEntryType(['zone@z2', 'ZoneName'])
('zone', 'active', ('zonePart', 2), ['ZoneName'])
>>> pf.determineEntryType(['zzz', 'ZoneName'])
('zone', 'active', ('zonePart', 2), ['ZoneName'])
def argsString(self, pieces: List[str]) -> str:
1026    def argsString(self, pieces: List[str]) -> str:
1027        """
1028        Recombines pieces of a journal argument (such as those produced
1029        by `unparseEffect`) into a single string. When there are
1030        multi-line or space-containing pieces, this adds block start/end
1031        delimiters and indents the piece if it's multi-line.
1032        """
1033        result = ''
1034        for piece in pieces:
1035            if '\n' in piece:
1036                result += (
1037                    f" {self.blockStart}\n"
1038                    f"{textwrap.indent(piece, '  ')}"
1039                    f"{self.blockEnd}"
1040                )
1041            elif ' ' in piece:
1042                result += f" {self.blockStart}{piece}{self.blockEnd}"
1043            else:
1044                result += ' ' + piece
1045
1046        return result[1:]  # chop off extra initial space

Recombines pieces of a journal argument (such as those produced by unparseEffect) into a single string. When there are multi-line or space-containing pieces, this adds block start/end delimiters and indents the piece if it's multi-line.

def removeComments(self, text: str) -> str:
1048    def removeComments(self, text: str) -> str:
1049        """
1050        Given one or more lines from a journal, removes all comments from
1051        it/them. Any '#' and any following characters through the end of
1052        a line counts as a comment.
1053
1054        Returns the text without comments.
1055
1056        Example:
1057
1058        >>> pf = JournalParseFormat()
1059        >>> pf.removeComments('abc # 123')
1060        'abc '
1061        >>> pf.removeComments('''\\
1062        ... line one # comment
1063        ... line two # comment
1064        ... line three
1065        ... line four # comment
1066        ... ''')
1067        'line one \\nline two \\nline three\\nline four \\n'
1068        """
1069        return self.commentRE.sub('', text)

Given one or more lines from a journal, removes all comments from it/them. Any '#' and any following characters through the end of a line counts as a comment.

Returns the text without comments.

Example:

>>> pf = JournalParseFormat()
>>> pf.removeComments('abc # 123')
'abc '
>>> pf.removeComments('''\
... line one # comment
... line two # comment
... line three
... line four # comment
... ''')
'line one \nline two \nline three\nline four \n'
def findBlockEnd(self, string: str, startIndex: int) -> int:
1071    def findBlockEnd(self, string: str, startIndex: int) -> int:
1072        """
1073        Given a string and a start index where a block open delimiter
1074        is, returns the index within the string of the matching block
1075        closing delimiter.
1076
1077        There are two possibilities: either both the opening and closing
1078        delimiter appear on the same line, or the block start appears at
1079        the end of a line (modulo whitespce) and the block end appears
1080        at the beginning of a line (modulo whitespace). Any other
1081        configuration is invalid and may lead to a `JournalParseError`.
1082
1083        Note that blocks may be nested within each other, including
1084        nesting single-line blocks in a multi-line block. It's also
1085        possible for several single-line blocks to appear on the same
1086        line.
1087
1088        Examples:
1089
1090        >>> pf = JournalParseFormat()
1091        >>> pf.findBlockEnd('[ A ]', 0)
1092        4
1093        >>> pf.findBlockEnd('[ A ] [ B ]', 0)
1094        4
1095        >>> pf.findBlockEnd('[ A ] [ B ]', 6)
1096        10
1097        >>> pf.findBlockEnd('[ A [ B ] ]', 0)
1098        10
1099        >>> pf.findBlockEnd('[ A [ B ] ]', 4)
1100        8
1101        >>> pf.findBlockEnd('[ [ B ]', 0)
1102        Traceback (most recent call last):
1103        ...
1104        exploration.journal.JournalParseError...
1105        >>> pf.findBlockEnd('[\\nABC\\n]', 0)
1106        6
1107        >>> pf.findBlockEnd('[\\nABC]', 0)  # End marker must start line
1108        Traceback (most recent call last):
1109        ...
1110        exploration.journal.JournalParseError...
1111        >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n  ]', 0)
1112        19
1113        >>> pf.findBlockEnd('[\\nABC\\nDEF[\\nGHI\\n]\\n  ]', 9)
1114        15
1115        >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n  ]', 0)
1116        19
1117        >>> pf.findBlockEnd('[\\nABC\\nDEF[ GHI ]\\n  ]', 9)
1118        15
1119        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 0)
1120        24
1121        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 11)
1122        22
1123        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n  ]\\n]', 16)
1124        18
1125        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H \\n  ]\\n]', 16)
1126        Traceback (most recent call last):
1127        ...
1128        exploration.journal.JournalParseError...
1129        >>> pf.findBlockEnd('[  \\nABC\\nDEF[\\nGHI[H]\\n\\n]', 0)
1130        Traceback (most recent call last):
1131        ...
1132        exploration.journal.JournalParseError...
1133        """
1134        # Find end of the line that the block opens on
1135        try:
1136            endOfLine = string.index('\n', startIndex)
1137        except ValueError:
1138            endOfLine = len(string)
1139
1140        # Determine if this is a single-line or multi-line block based
1141        # on the presence of *anything* after the opening delimiter
1142        restOfLine = string[startIndex + 1:endOfLine]
1143        if restOfLine.strip() != '':  # A single-line block
1144            level = 1
1145            for restIndex, char in enumerate(restOfLine):
1146                if char == self.blockEnd:
1147                    level -= 1
1148                    if level <= 0:
1149                        break
1150                elif char == self.blockStart:
1151                    level += 1
1152
1153            if level == 0:
1154                return startIndex + 1 + restIndex
1155            else:
1156                raise JournalParseError(
1157                    f"Got to end of line in single-line block without"
1158                    f" finding the matching end-of-block marker."
1159                    f" Remainder of line is:\n  {restOfLine!r}"
1160                )
1161
1162        else:  # It's a multi-line block
1163            level = 1
1164            index = startIndex + 1
1165            while level > 0 and index < len(string):
1166                nextStart = self.blockStartRE.search(string, index)
1167                nextEnd = self.blockEndRE.search(string, index)
1168                if nextEnd is None:
1169                    break  # no end in sight; level won't be 0
1170                elif (
1171                    nextStart is None
1172                 or nextStart.start() > nextEnd.start()
1173                ):
1174                    index = nextEnd.end()
1175                    level -= 1
1176                    if level <= 0:
1177                        break
1178                else:  # They cannot be equal
1179                    index = nextStart.end()
1180                    level += 1
1181
1182            if level == 0:
1183                if nextEnd is None:
1184                    raise RuntimeError(
1185                        "Parsing got to level 0 with no valid end"
1186                        " match."
1187                    )
1188                return nextEnd.end() - 1
1189            else:
1190                raise JournalParseError(
1191                    f"Got to the end of the entire string and didn't"
1192                    f" find a matching end-of-block marker. Started at"
1193                    f" index {startIndex}."
1194                )

Given a string and a start index where a block open delimiter is, returns the index within the string of the matching block closing delimiter.

There are two possibilities: either both the opening and closing delimiter appear on the same line, or the block start appears at the end of a line (modulo whitespce) and the block end appears at the beginning of a line (modulo whitespace). Any other configuration is invalid and may lead to a JournalParseError.

Note that blocks may be nested within each other, including nesting single-line blocks in a multi-line block. It's also possible for several single-line blocks to appear on the same line.

Examples:

>>> pf = JournalParseFormat()
>>> pf.findBlockEnd('[ A ]', 0)
4
>>> pf.findBlockEnd('[ A ] [ B ]', 0)
4
>>> pf.findBlockEnd('[ A ] [ B ]', 6)
10
>>> pf.findBlockEnd('[ A [ B ] ]', 0)
10
>>> pf.findBlockEnd('[ A [ B ] ]', 4)
8
>>> pf.findBlockEnd('[ [ B ]', 0)
Traceback (most recent call last):
...
JournalParseError...
>>> pf.findBlockEnd('[\nABC\n]', 0)
6
>>> pf.findBlockEnd('[\nABC]', 0)  # End marker must start line
Traceback (most recent call last):
...
JournalParseError...
>>> pf.findBlockEnd('[\nABC\nDEF[\nGHI\n]\n  ]', 0)
19
>>> pf.findBlockEnd('[\nABC\nDEF[\nGHI\n]\n  ]', 9)
15
>>> pf.findBlockEnd('[\nABC\nDEF[ GHI ]\n  ]', 0)
19
>>> pf.findBlockEnd('[\nABC\nDEF[ GHI ]\n  ]', 9)
15
>>> pf.findBlockEnd('[  \nABC\nDEF[\nGHI[H]\n  ]\n]', 0)
24
>>> pf.findBlockEnd('[  \nABC\nDEF[\nGHI[H]\n  ]\n]', 11)
22
>>> pf.findBlockEnd('[  \nABC\nDEF[\nGHI[H]\n  ]\n]', 16)
18
>>> pf.findBlockEnd('[  \nABC\nDEF[\nGHI[H \n  ]\n]', 16)
Traceback (most recent call last):
...
JournalParseError...
>>> pf.findBlockEnd('[  \nABC\nDEF[\nGHI[H]\n\n]', 0)
Traceback (most recent call last):
...
JournalParseError...
class JournalParseError(builtins.ValueError):
1201class JournalParseError(ValueError):
1202    """
1203    Represents a error encountered when parsing a journal.
1204    """
1205    pass

Represents a error encountered when parsing a journal.

Inherited Members
builtins.ValueError
ValueError
builtins.BaseException
with_traceback
add_note
args
class LocatedJournalParseError(JournalParseError):
1208class LocatedJournalParseError(JournalParseError):
1209    """
1210    An error during journal parsing that includes additional location
1211    information.
1212    """
1213    def __init__(
1214        self,
1215        src: str,
1216        index: Optional[int],
1217        cause: Exception
1218    ) -> None:
1219        """
1220        In addition to the underlying error, the journal source text and
1221        the index within that text where the error occurred are
1222        required.
1223        """
1224        super().__init__("localized error")
1225        self.src = src
1226        self.index = index
1227        self.cause = cause
1228
1229    def __str__(self) -> str:
1230        """
1231        Includes information about the location of the error and the
1232        line it appeared on.
1233        """
1234        ec = errorContext(self.src, self.index)
1235        errorCM = textwrap.indent(errorContextMessage(ec), '  ')
1236        return (
1237            f"\n{errorCM}"
1238            f"\n  Error is:"
1239            f"\n{type(self.cause).__name__}: {self.cause}"
1240        )

An error during journal parsing that includes additional location information.

LocatedJournalParseError(src: str, index: Optional[int], cause: Exception)
1213    def __init__(
1214        self,
1215        src: str,
1216        index: Optional[int],
1217        cause: Exception
1218    ) -> None:
1219        """
1220        In addition to the underlying error, the journal source text and
1221        the index within that text where the error occurred are
1222        required.
1223        """
1224        super().__init__("localized error")
1225        self.src = src
1226        self.index = index
1227        self.cause = cause

In addition to the underlying error, the journal source text and the index within that text where the error occurred are required.

src
index
cause
Inherited Members
builtins.BaseException
with_traceback
add_note
args
def errorContext(string: str, index: Optional[int]) -> Optional[Tuple[str, int, int]]:
1243def errorContext(
1244    string: str,
1245    index: Optional[int]
1246) -> Optional[Tuple[str, int, int]]:
1247    """
1248    Returns the line of text, the line number, and the character within
1249    that line for the given absolute index into the given string.
1250    Newline characters count as the last character on their line. Lines
1251    and characters are numbered starting from 1.
1252
1253    Returns `None` for out-of-range indices.
1254
1255    Examples:
1256
1257    >>> errorContext('a\\nb\\nc', 0)
1258    ('a\\n', 1, 1)
1259    >>> errorContext('a\\nb\\nc', 1)
1260    ('a\\n', 1, 2)
1261    >>> errorContext('a\\nbcd\\ne', 2)
1262    ('bcd\\n', 2, 1)
1263    >>> errorContext('a\\nbcd\\ne', 3)
1264    ('bcd\\n', 2, 2)
1265    >>> errorContext('a\\nbcd\\ne', 4)
1266    ('bcd\\n', 2, 3)
1267    >>> errorContext('a\\nbcd\\ne', 5)
1268    ('bcd\\n', 2, 4)
1269    >>> errorContext('a\\nbcd\\ne', 6)
1270    ('e', 3, 1)
1271    >>> errorContext('a\\nbcd\\ne', -1)
1272    ('e', 3, 1)
1273    >>> errorContext('a\\nbcd\\ne', -2)
1274    ('bcd\\n', 2, 4)
1275    >>> errorContext('a\\nbcd\\ne', 7) is None
1276    True
1277    >>> errorContext('a\\nbcd\\ne', 8) is None
1278    True
1279    """
1280    # Return None if no index is given
1281    if index is None:
1282        return None
1283
1284    # Convert negative to positive indices
1285    if index < 0:
1286        index = len(string) + index
1287
1288    # Return None for out-of-range indices
1289    if not 0 <= index < len(string):
1290        return None
1291
1292    # Count lines + look for start-of-line
1293    line = 1
1294    lineStart = 0
1295    for where, char in enumerate(string):
1296        if where >= index:
1297            break
1298        if char == '\n':
1299            line += 1
1300            lineStart = where + 1
1301
1302    try:
1303        endOfLine = string.index('\n', where)
1304    except ValueError:
1305        endOfLine = len(string)
1306
1307    return (string[lineStart:endOfLine + 1], line, index - lineStart + 1)

Returns the line of text, the line number, and the character within that line for the given absolute index into the given string. Newline characters count as the last character on their line. Lines and characters are numbered starting from 1.

Returns None for out-of-range indices.

Examples:

>>> errorContext('a\nb\nc', 0)
('a\n', 1, 1)
>>> errorContext('a\nb\nc', 1)
('a\n', 1, 2)
>>> errorContext('a\nbcd\ne', 2)
('bcd\n', 2, 1)
>>> errorContext('a\nbcd\ne', 3)
('bcd\n', 2, 2)
>>> errorContext('a\nbcd\ne', 4)
('bcd\n', 2, 3)
>>> errorContext('a\nbcd\ne', 5)
('bcd\n', 2, 4)
>>> errorContext('a\nbcd\ne', 6)
('e', 3, 1)
>>> errorContext('a\nbcd\ne', -1)
('e', 3, 1)
>>> errorContext('a\nbcd\ne', -2)
('bcd\n', 2, 4)
>>> errorContext('a\nbcd\ne', 7) is None
True
>>> errorContext('a\nbcd\ne', 8) is None
True
def errorContextMessage(context: Optional[Tuple[str, int, int]]) -> str:
1310def errorContextMessage(context: Optional[Tuple[str, int, int]]) -> str:
1311    """
1312    Given an error context tuple (from `errorContext`) or possibly
1313    `None`, returns a string that can be used as part of an error
1314    message, identifying where the error occurred.
1315    """
1316    line: Union[int, str]
1317    pos: Union[int, str]
1318    if context is None:
1319        contextStr = "<context unavialable>"
1320        line = "?"
1321        pos = "?"
1322    else:
1323        contextStr, line, pos = context
1324        contextStr = contextStr.rstrip('\n')
1325    return (
1326        f"In journal on line {line} near character {pos}:"
1327        f"  {contextStr}"
1328    )

Given an error context tuple (from errorContext) or possibly None, returns a string that can be used as part of an error message, identifying where the error occurred.

class JournalParseWarning(builtins.Warning):
1331class JournalParseWarning(Warning):
1332    """
1333    Represents a warning encountered when parsing a journal.
1334    """
1335    pass

Represents a warning encountered when parsing a journal.

Inherited Members
builtins.Warning
Warning
builtins.BaseException
with_traceback
add_note
args
class PathEllipsis:
1338class PathEllipsis:
1339    """
1340    Represents part of a path which has been omitted from a journal and
1341    which should therefore be inferred.
1342    """
1343    pass

Represents part of a path which has been omitted from a journal and which should therefore be inferred.

class ObservationContext(typing.TypedDict):
1350class ObservationContext(TypedDict):
1351    """
1352    The context for an observation, including which context (common or
1353    active) is being used, which domain we're focused on, which focal
1354    point is being modified for plural-focalized domains, and which
1355    decision and transition within the current domain are most relevant
1356    right now.
1357    """
1358    context: base.ContextSpecifier
1359    domain: base.Domain
1360    # TODO: Per-domain focus/decision/transitions?
1361    focus: Optional[base.FocalPointName]
1362    decision: Optional[base.DecisionID]
1363    transition: Optional[Tuple[base.DecisionID, base.Transition]]

The context for an observation, including which context (common or active) is being used, which domain we're focused on, which focal point is being modified for plural-focalized domains, and which decision and transition within the current domain are most relevant right now.

context: Literal['common', 'active']
domain: str
focus: Optional[str]
decision: Optional[int]
transition: Optional[Tuple[int, str]]
def observationContext( context: Literal['common', 'active'] = 'active', domain: str = 'main', focus: Optional[str] = None, decision: Optional[int] = None, transition: Optional[Tuple[int, str]] = None) -> ObservationContext:
1366def observationContext(
1367    context: base.ContextSpecifier = "active",
1368    domain: base.Domain = base.DEFAULT_DOMAIN,
1369    focus: Optional[base.FocalPointName] = None,
1370    decision: Optional[base.DecisionID] = None,
1371    transition: Optional[Tuple[base.DecisionID, base.Transition]] = None
1372) -> ObservationContext:
1373    """
1374    Creates a default/empty `ObservationContext`.
1375    """
1376    return {
1377        'context': context,
1378        'domain': domain,
1379        'focus': focus,
1380        'decision': decision,
1381        'transition': transition
1382    }

Creates a default/empty ObservationContext.

class ObservationPreferences(typing.TypedDict):
1385class ObservationPreferences(TypedDict):
1386    """
1387    Specifies global preferences for exploration observation. Values are
1388    either strings or booleans. The keys are:
1389
1390    - 'reciprocals': A boolean specifying whether transitions should
1391        come with reciprocals by default. Normally this is `True`, but
1392        it can be set to `False` instead.
1393        TODO: implement this.
1394    - 'revertAspects': A set of strings specifying which aspects of the
1395        game state should be reverted when a 'revert' action is taken and
1396        specific aspects to revert are not specified. See
1397        `base.revertedState` for a list of the available reversion
1398        aspects.
1399    """
1400    reciprocals: bool
1401    revertAspects: Set[str]

Specifies global preferences for exploration observation. Values are either strings or booleans. The keys are:

  • 'reciprocals': A boolean specifying whether transitions should come with reciprocals by default. Normally this is True, but it can be set to False instead. TODO: implement this.
  • 'revertAspects': A set of strings specifying which aspects of the game state should be reverted when a 'revert' action is taken and specific aspects to revert are not specified. See base.revertedState for a list of the available reversion aspects.
reciprocals: bool
revertAspects: Set[str]
def observationPreferences( reciprocals: bool = True, revertAspects: Optional[Set[str]] = None) -> ObservationPreferences:
1404def observationPreferences(
1405    reciprocals: bool=True,
1406    revertAspects: Optional[Set[str]] = None
1407) -> ObservationPreferences:
1408    """
1409    Creates an observation preferences dictionary, using default values
1410    for any preferences not specified as arguments.
1411    """
1412    return {
1413        'reciprocals': reciprocals,
1414        'revertAspects': (
1415            revertAspects
1416            if revertAspects is not None
1417            else set()
1418        )
1419    }

Creates an observation preferences dictionary, using default values for any preferences not specified as arguments.

class JournalObserver:
1422class JournalObserver:
1423    """
1424    Keeps track of extra state needed when parsing a journal in order to
1425    produce a `core.DiscreteExploration` object. The methods of this
1426    class act as an API for constructing explorations that have several
1427    special properties. The API is designed to allow journal entries
1428    (which represent specific observations/events during an exploration)
1429    to be directly accumulated into an exploration object, including
1430    entries which apply to things like the most-recent-decision or
1431    -transition.
1432
1433    You can use the `convertJournal` function to handle things instead,
1434    since that function creates and manages a `JournalObserver` object
1435    for you.
1436
1437    The basic usage is as follows:
1438
1439    1. Create a `JournalObserver`, optionally specifying a custom
1440        `ParseFormat`.
1441    2. Repeatedly either:
1442        * Call `record*` API methods corresponding to specific entries
1443            observed or...
1444        * Call `JournalObserver.observe` to parse one or more
1445            journal blocks from a string and call the appropriate
1446            methods automatically.
1447    3. Call `JournalObserver.getExploration` to retrieve the
1448        `core.DiscreteExploration` object that's been created.
1449
1450    You can just call `convertJournal` to do all of these things at
1451    once.
1452
1453    Notes:
1454
1455    - `JournalObserver.getExploration` may be called at any time to get
1456        the exploration object constructed so far, and that that object
1457        (unless it's `None`) will always be the same object (which gets
1458        modified as entries are recorded). Modifying this object
1459        directly is possible for making changes not available via the
1460        API, but must be done carefully, as there are important
1461        conventions around things like decision names that must be
1462        respected if the API functions need to keep working.
1463    - To get the latest graph or state, simply use the
1464        `core.DiscreteExploration.getSituation()` method of the
1465        `JournalObserver.getExploration` result.
1466
1467    ## Examples
1468
1469    >>> obs = JournalObserver()
1470    >>> e = obs.getExploration()
1471    >>> len(e) # blank starting state
1472    1
1473    >>> e.getActiveDecisions(0)  # no active decisions before starting
1474    set()
1475    >>> obs.definiteDecisionTarget()
1476    Traceback (most recent call last):
1477    ...
1478    exploration.core.MissingDecisionError...
1479    >>> obs.currentDecisionTarget() is None
1480    True
1481    >>> # We start by using the record* methods...
1482    >>> obs.recordStart("Start")
1483    >>> obs.definiteDecisionTarget()
1484    0
1485    >>> obs.recordObserve("bottom")
1486    >>> obs.definiteDecisionTarget()
1487    0
1488    >>> len(e) # blank + started states
1489    2
1490    >>> e.getActiveDecisions(1)
1491    {0}
1492    >>> obs.recordExplore("left", "West", "right")
1493    >>> obs.definiteDecisionTarget()
1494    2
1495    >>> len(e) # starting states + one step
1496    3
1497    >>> e.getActiveDecisions(1)
1498    {0}
1499    >>> e.movementAtStep(1)
1500    (0, 'left', 2)
1501    >>> e.getActiveDecisions(2)
1502    {2}
1503    >>> e.getActiveDecisions()
1504    {2}
1505    >>> e.getSituation().graph.nameFor(list(e.getActiveDecisions())[0])
1506    'West'
1507    >>> obs.recordRetrace("right")  # back at Start
1508    >>> obs.definiteDecisionTarget()
1509    0
1510    >>> len(e) # starting states + two steps
1511    4
1512    >>> e.getActiveDecisions(1)
1513    {0}
1514    >>> e.movementAtStep(1)
1515    (0, 'left', 2)
1516    >>> e.getActiveDecisions(2)
1517    {2}
1518    >>> e.movementAtStep(2)
1519    (2, 'right', 0)
1520    >>> e.getActiveDecisions(3)
1521    {0}
1522    >>> obs.recordRetrace("bad") # transition doesn't exist
1523    Traceback (most recent call last):
1524    ...
1525    exploration.journal.JournalParseError...
1526    >>> obs.definiteDecisionTarget()
1527    0
1528    >>> obs.recordObserve('right', 'East', 'left')
1529    >>> e.getSituation().graph.getTransitionRequirement('Start', 'right')
1530    ReqNothing()
1531    >>> obs.recordRequirement('crawl|small')
1532    >>> e.getSituation().graph.getTransitionRequirement('Start', 'right')
1533    ReqAny([ReqCapability('crawl'), ReqCapability('small')])
1534    >>> obs.definiteDecisionTarget()
1535    0
1536    >>> obs.currentTransitionTarget()
1537    (0, 'right')
1538    >>> obs.currentReciprocalTarget()
1539    (3, 'left')
1540    >>> g = e.getSituation().graph
1541    >>> print(g.namesListing(g).rstrip('\\n'))
1542      0 (Start)
1543      1 (_u.0)
1544      2 (West)
1545      3 (East)
1546    >>> # The use of relative mode to add remote observations
1547    >>> obs.relative('East')
1548    >>> obs.definiteDecisionTarget()
1549    3
1550    >>> obs.recordObserve('top_vent')
1551    >>> obs.recordRequirement('crawl')
1552    >>> obs.recordReciprocalRequirement('crawl')
1553    >>> obs.recordMechanism('East', 'door', 'closed')  # door starts closed
1554    >>> obs.recordAction('lever')
1555    >>> obs.recordTransitionConsequence(
1556    ...     [base.effect(set=("door", "open")), base.effect(deactivate=True)]
1557    ... )  # lever opens the door
1558    >>> obs.recordExplore('right_door', 'Outside', 'left_door')
1559    >>> obs.definiteDecisionTarget()
1560    5
1561    >>> obs.recordRequirement('door:open')
1562    >>> obs.recordReciprocalRequirement('door:open')
1563    >>> obs.definiteDecisionTarget()
1564    5
1565    >>> obs.exploration.getExplorationStatus('East')
1566    'noticed'
1567    >>> obs.exploration.hasBeenVisited('East')
1568    False
1569    >>> obs.exploration.getExplorationStatus('Outside')
1570    'noticed'
1571    >>> obs.exploration.hasBeenVisited('Outside')
1572    False
1573    >>> obs.relative() # leave relative mode
1574    >>> len(e) # starting states + two steps, no steps happen in relative mode
1575    4
1576    >>> obs.definiteDecisionTarget()  # out of relative mode; at Start
1577    0
1578    >>> g = e.getSituation().graph
1579    >>> g.getTransitionRequirement(
1580    ...     g.getDestination('East', 'top_vent'),
1581    ...     'return'
1582    ... )
1583    ReqCapability('crawl')
1584    >>> g.getTransitionRequirement('East', 'top_vent')
1585    ReqCapability('crawl')
1586    >>> g.getTransitionRequirement('East', 'right_door')
1587    ReqMechanism('door', 'open')
1588    >>> g.getTransitionRequirement('Outside', 'left_door')
1589    ReqMechanism('door', 'open')
1590    >>> print(g.namesListing(g).rstrip('\\n'))
1591      0 (Start)
1592      1 (_u.0)
1593      2 (West)
1594      3 (East)
1595      4 (_u.3)
1596      5 (Outside)
1597    >>> # Now we demonstrate the use of "observe"
1598    >>> e.getActiveDecisions()
1599    {0}
1600    >>> g.destinationsFrom(0)
1601    {'bottom': 1, 'left': 2, 'right': 3}
1602    >>> g.getDecision('Attic') is None
1603    True
1604    >>> obs.definiteDecisionTarget()
1605    0
1606    >>> obs.observe("\
1607o up Attic down\\n\
1608x up\\n\
1609   n at: Attic\\n\
1610o vent\\n\
1611q crawl")
1612    >>> g = e.getSituation().graph
1613    >>> print(g.namesListing(g).rstrip('\\n'))
1614      0 (Start)
1615      1 (_u.0)
1616      2 (West)
1617      3 (East)
1618      4 (_u.3)
1619      5 (Outside)
1620      6 (Attic)
1621      7 (_u.6)
1622    >>> g.destinationsFrom(0)
1623    {'bottom': 1, 'left': 2, 'right': 3, 'up': 6}
1624    >>> g.nameFor(list(e.getActiveDecisions())[0])
1625    'Attic'
1626    >>> g.getTransitionRequirement('Attic', 'vent')
1627    ReqCapability('crawl')
1628    >>> sorted(list(g.destinationsFrom('Attic').items()))
1629    [('down', 0), ('vent', 7)]
1630    >>> obs.definiteDecisionTarget()  # in the Attic
1631    6
1632    >>> obs.observe("\
1633a getCrawl\\n\
1634  At gain crawl\\n\
1635x vent East top_vent")  # connecting to a previously-observed transition
1636    >>> g = e.getSituation().graph
1637    >>> print(g.namesListing(g).rstrip('\\n'))
1638      0 (Start)
1639      1 (_u.0)
1640      2 (West)
1641      3 (East)
1642      5 (Outside)
1643      6 (Attic)
1644    >>> g.getTransitionRequirement('East', 'top_vent')
1645    ReqCapability('crawl')
1646    >>> g.nameFor(g.getDestination('Attic', 'vent'))
1647    'East'
1648    >>> g.nameFor(g.getDestination('East', 'top_vent'))
1649    'Attic'
1650    >>> len(e) # exploration, action, and return are each 1
1651    7
1652    >>> e.getActiveDecisions(3)
1653    {0}
1654    >>> e.movementAtStep(3)
1655    (0, 'up', 6)
1656    >>> e.getActiveDecisions(4)
1657    {6}
1658    >>> g.nameFor(list(e.getActiveDecisions(4))[0])
1659    'Attic'
1660    >>> e.movementAtStep(4)
1661    (6, 'getCrawl', 6)
1662    >>> g.nameFor(list(e.getActiveDecisions(5))[0])
1663    'Attic'
1664    >>> e.movementAtStep(5)
1665    (6, 'vent', 3)
1666    >>> g.nameFor(list(e.getActiveDecisions(6))[0])
1667    'East'
1668    >>> # Now let's pull the lever and go outside, but first, we'll
1669    >>> # return to the Start to demonstrate recordRetrace
1670    >>> # note that recordReturn only applies when the destination of the
1671    >>> # transition is not already known.
1672    >>> obs.recordRetrace('left')  # back to Start
1673    >>> obs.definiteDecisionTarget()
1674    0
1675    >>> obs.recordRetrace('right')  # and back to East
1676    >>> obs.definiteDecisionTarget()
1677    3
1678    >>> obs.exploration.mechanismState('door')
1679    'closed'
1680    >>> obs.recordRetrace('lever', isAction=True)  # door is now open
1681    >>> obs.exploration.mechanismState('door')
1682    'open'
1683    >>> obs.exploration.getExplorationStatus('Outside')
1684    'noticed'
1685    >>> obs.recordExplore('right_door')
1686    >>> obs.definiteDecisionTarget()  # now we're Outside
1687    5
1688    >>> obs.recordReturn('tunnelUnder', 'Start', 'bottom')
1689    >>> obs.definiteDecisionTarget()  # back at the start
1690    0
1691    >>> g = e.getSituation().graph
1692    >>> print(g.namesListing(g).rstrip('\\n'))
1693      0 (Start)
1694      2 (West)
1695      3 (East)
1696      5 (Outside)
1697      6 (Attic)
1698    >>> g.destinationsFrom(0)
1699    {'left': 2, 'right': 3, 'up': 6, 'bottom': 5}
1700    >>> g.destinationsFrom(5)
1701    {'left_door': 3, 'tunnelUnder': 0}
1702
1703    An example of the use of `recordUnify` and `recordObviate`.
1704
1705    >>> obs = JournalObserver()
1706    >>> obs.observe('''
1707    ... S start
1708    ... x right hall left
1709    ... x right room left
1710    ... x vent vents right_vent
1711    ... ''')
1712    >>> obs.recordObviate('middle_vent', 'hall', 'vent')
1713    >>> obs.recordExplore('left_vent', 'new_room', 'vent')
1714    >>> obs.recordUnify('start')
1715    >>> e = obs.getExploration()
1716    >>> len(e)
1717    6
1718    >>> e.getActiveDecisions(0)
1719    set()
1720    >>> [
1721    ...     e.getSituation(n).graph.nameFor(list(e.getActiveDecisions(n))[0])
1722    ...     for n in range(1, 6)
1723    ... ]
1724    ['start', 'hall', 'room', 'vents', 'start']
1725    >>> g = e.getSituation().graph
1726    >>> g.getDestination('start', 'vent')
1727    3
1728    >>> g.getDestination('vents', 'left_vent')
1729    0
1730    >>> g.getReciprocal('start', 'vent')
1731    'left_vent'
1732    >>> g.getReciprocal('vents', 'left_vent')
1733    'vent'
1734    >>> 'new_room' in g
1735    False
1736    """
1737
1738    parseFormat: JournalParseFormat
1739    """
1740    The parse format used to parse entries supplied as text. This also
1741    ends up controlling some of the decision and transition naming
1742    conventions that are followed, so it is not safe to change it
1743    mid-journal; it should be set once before observation begins, and
1744    may be accessed but should not be changed.
1745    """
1746
1747    exploration: core.DiscreteExploration
1748    """
1749    This is the exploration object being built via journal observations.
1750    Note that the exploration object may be empty (i.e., have length 0)
1751    even after the first few entries have been recorded because in some
1752    cases entries are ambiguous and are not translated into exploration
1753    steps until a further entry resolves that ambiguity.
1754    """
1755
1756    preferences: ObservationPreferences
1757    """
1758    Preferences for the observation mechanisms. See
1759    `ObservationPreferences`.
1760    """
1761
1762    uniqueNumber: int
1763    """
1764    A unique number to be substituted (prefixed with '_') into
1765    underscore-substitutions within aliases. Will be incremented for each
1766    such substitution.
1767    """
1768
1769    aliases: Dict[str, Tuple[List[str], str]]
1770    """
1771    The defined aliases for this observer. Each alias has a name, and
1772    stored under that name is a list of parameters followed by a
1773    commands string.
1774    """
1775
1776    def __init__(self, parseFormat: Optional[JournalParseFormat] = None):
1777        """
1778        Sets up the observer. If a parse format is supplied, that will
1779        be used instead of the default parse format, which is just the
1780        result of creating a `ParseFormat` with default arguments.
1781
1782        A simple example:
1783
1784        >>> o = JournalObserver()
1785        >>> o.recordStart('hi')
1786        >>> o.exploration.getExplorationStatus('hi')
1787        'exploring'
1788        >>> e = o.getExploration()
1789        >>> len(e)
1790        2
1791        >>> g = e.getSituation().graph
1792        >>> len(g)
1793        1
1794        >>> e.getActiveContext()
1795        {\
1796'capabilities': {'capabilities': set(), 'tokens': {}, 'skills': {}},\
1797 'focalization': {'main': 'singular'},\
1798 'activeDomains': {'main'},\
1799 'activeDecisions': {'main': 0}\
1800}
1801        >>> list(g.nodes)[0]
1802        0
1803        >>> o.recordObserve('option')
1804        >>> list(g.nodes)
1805        [0, 1]
1806        >>> [g.nameFor(d) for d in g.nodes]
1807        ['hi', '_u.0']
1808        >>> o.recordZone(0, 'Lower')
1809        >>> [g.nameFor(d) for d in g.nodes]
1810        ['hi', '_u.0']
1811        >>> e.getActiveDecisions()
1812        {0}
1813        >>> o.recordZone(1, 'Upper')
1814        >>> o.recordExplore('option', 'bye', 'back')
1815        >>> g = e.getSituation().graph
1816        >>> [g.nameFor(d) for d in g.nodes]
1817        ['hi', 'bye']
1818        >>> o.recordObserve('option2')
1819        >>> import pytest
1820        >>> oldWarn = core.WARN_OF_NAME_COLLISIONS
1821        >>> core.WARN_OF_NAME_COLLISIONS = True
1822        >>> try:
1823        ...     with pytest.warns(core.DecisionCollisionWarning):
1824        ...         o.recordExplore('option2', 'Lower2::hi', 'back')
1825        ... finally:
1826        ...     core.WARN_OF_NAME_COLLISIONS = oldWarn
1827        >>> g = e.getSituation().graph
1828        >>> [g.nameFor(d) for d in g.nodes]
1829        ['hi', 'bye', 'hi']
1830        >>> # Prefix must be specified because it's ambiguous
1831        >>> o.recordWarp('Lower::hi')
1832        >>> g = e.getSituation().graph
1833        >>> [(d, g.nameFor(d)) for d in g.nodes]
1834        [(0, 'hi'), (1, 'bye'), (2, 'hi')]
1835        >>> e.getActiveDecisions()
1836        {0}
1837        >>> o.recordWarp('bye')
1838        >>> g = e.getSituation().graph
1839        >>> [(d, g.nameFor(d)) for d in g.nodes]
1840        [(0, 'hi'), (1, 'bye'), (2, 'hi')]
1841        >>> e.getActiveDecisions()
1842        {1}
1843        """
1844        if parseFormat is None:
1845            self.parseFormat = JournalParseFormat()
1846        else:
1847            self.parseFormat = parseFormat
1848
1849        self.uniqueNumber = 0
1850        self.aliases = {}
1851
1852        # Set up default observation preferences
1853        self.preferences = observationPreferences()
1854
1855        # Create a blank exploration
1856        self.exploration = core.DiscreteExploration()
1857
1858        # Debugging support
1859        self.prevSteps: Optional[int] = None
1860        self.prevDecisions: Optional[int] = None
1861
1862        # Current context tracking focal context, domain, focus point,
1863        # decision, and/or transition that's currently most relevant:
1864        self.context = observationContext()
1865
1866        # TODO: Stack of contexts?
1867        # Stored observation context can be restored as the current
1868        # state later. This is used to support relative mode.
1869        self.storedContext: Optional[
1870            ObservationContext
1871        ] = None
1872
1873        # Whether or not we're in relative mode.
1874        self.inRelativeMode = False
1875
1876        # Tracking which decisions we shouldn't auto-finalize
1877        self.dontFinalize: Set[base.DecisionID] = set()
1878
1879        # Tracking current parse location for errors & warnings
1880        self.journalTexts: List[str] = []  # a stack 'cause of macros
1881        self.parseIndices: List[int] = []  # also a stack
1882
1883    def getExploration(self) -> core.DiscreteExploration:
1884        """
1885        Returns the exploration that this observer edits.
1886        """
1887        return self.exploration
1888
1889    def nextUniqueName(self) -> str:
1890        """
1891        Returns the next unique name for this observer, which is just an
1892        underscore followed by an integer. This increments
1893        `uniqueNumber`.
1894        """
1895        result = '_' + str(self.uniqueNumber)
1896        self.uniqueNumber += 1
1897        return result
1898
1899    def currentDecisionTarget(self) -> Optional[base.DecisionID]:
1900        """
1901        Returns the decision which decision-based changes should be
1902        applied to. Changes depending on whether relative mode is
1903        active. Will be `None` when there is no current position (e.g.,
1904        before the exploration is started).
1905        """
1906        return self.context['decision']
1907
1908    def definiteDecisionTarget(self) -> base.DecisionID:
1909        """
1910        Works like `currentDecisionTarget` but raises a
1911        `core.MissingDecisionError` instead of returning `None` if there
1912        is no current decision.
1913        """
1914        result = self.currentDecisionTarget()
1915
1916        if result is None:
1917            raise core.MissingDecisionError("There is no current decision.")
1918        else:
1919            return result
1920
1921    def decisionTargetSpecifier(self) -> base.DecisionSpecifier:
1922        """
1923        Returns a `base.DecisionSpecifier` which includes domain, zone,
1924        and name for the current decision. The zone used is the first
1925        alphabetical lowest-level zone that the decision is in, which
1926        *could* in some cases remain ambiguous. If you're worried about
1927        that, use `definiteDecisionTarget` instead.
1928
1929        Like `definiteDecisionTarget` this will crash if there isn't a
1930        current decision target.
1931        """
1932        graph = self.exploration.getSituation().graph
1933        dID = self.definiteDecisionTarget()
1934        domain = graph.domainFor(dID)
1935        name = graph.nameFor(dID)
1936        inZones = graph.zoneAncestors(dID)
1937        # Alphabetical order (we have no better option)
1938        ordered = sorted(
1939            inZones,
1940            key=lambda z: (
1941                graph.zoneHierarchyLevel(z),   # level-0 first
1942                z  # alphabetical as tie-breaker
1943            )
1944        )
1945        if len(ordered) > 0:
1946            useZone = ordered[0]
1947        else:
1948            useZone = None
1949
1950        return base.DecisionSpecifier(
1951            domain=domain,
1952            zone=useZone,
1953            name=name
1954        )
1955
1956    def currentTransitionTarget(
1957        self
1958    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
1959        """
1960        Returns the decision, transition pair that identifies the current
1961        transition which transition-based changes should apply to. Will
1962        be `None` when there is no current transition (e.g., just after a
1963        warp).
1964        """
1965        transition = self.context['transition']
1966        if transition is None:
1967            return None
1968        else:
1969            return transition
1970
1971    def currentReciprocalTarget(
1972        self
1973    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
1974        """
1975        Returns the decision, transition pair that identifies the
1976        reciprocal of the `currentTransitionTarget`. Will be `None` when
1977        there is no current transition, or when the current transition
1978        doesn't have a reciprocal (e.g., after an ending).
1979        """
1980        # relative mode is handled by `currentTransitionTarget`
1981        target = self.currentTransitionTarget()
1982        if target is None:
1983            return None
1984        return self.exploration.getSituation().graph.getReciprocalPair(
1985            *target
1986        )
1987
1988    def checkFormat(
1989        self,
1990        entryType: str,
1991        decisionType: base.DecisionType,
1992        target: Union[None, JournalTargetType, Tuple[JournalTargetType, int]],
1993        pieces: List[str],
1994        expectedTargets: Union[
1995            None,
1996            JournalTargetType,
1997            Collection[
1998                Union[None, JournalTargetType]
1999            ]
2000        ],
2001        expectedPieces: Union[None, int, Collection[int]]
2002    ) -> None:
2003        """
2004        Does format checking for a journal entry after
2005        `determineEntryType` is called. Checks that:
2006
2007        - A decision type other than 'active' is only used for entries
2008            where that makes sense.
2009        - The target is one from an allowed list of targets (or is `None`
2010            if `expectedTargets` is set to `None`)
2011        - The number of pieces of content is a specific number or within
2012            a specific collection of allowed numbers. If `expectedPieces`
2013            is set to None, there is no restriction on the number of
2014            pieces.
2015
2016        Raises a `JournalParseError` if its expectations are violated.
2017        """
2018        if decisionType != 'active' and entryType not in (
2019            'START',
2020            'explore',
2021            'retrace',
2022            'return',
2023            'action',
2024            'warp',
2025            'wait',
2026            'END',
2027            'revert'
2028        ):
2029            raise JournalParseError(
2030                f"{entryType} entry may not specify a non-standard"
2031                f" decision type (got {decisionType!r}), because it is"
2032                f" not associated with an exploration action."
2033            )
2034
2035        if expectedTargets is None:
2036            if target is not None:
2037                raise JournalParseError(
2038                    f"{entryType} entry may not specify a target."
2039                )
2040        else:
2041            if isinstance(expectedTargets, str):
2042                expected = cast(
2043                    Collection[Union[None, JournalTargetType]],
2044                    [expectedTargets]
2045                )
2046            else:
2047                expected = cast(
2048                    Collection[Union[None, JournalTargetType]],
2049                    expectedTargets
2050                )
2051            tType = target
2052            if isinstance(tType, tuple):
2053                tType = tType[0]
2054
2055            if tType not in expected:
2056                raise JournalParseError(
2057                    f"{entryType} entry had invalid target {target!r}."
2058                    f" Expected one of:\n{expected}"
2059                )
2060
2061        if expectedPieces is None:
2062            # No restriction
2063            pass
2064        elif isinstance(expectedPieces, int):
2065            if len(pieces) != expectedPieces:
2066                raise JournalParseError(
2067                    f"{entryType} entry had {len(pieces)} arguments but"
2068                    f" only {expectedPieces} argument(s) is/are allowed."
2069                )
2070
2071        elif len(pieces) not in expectedPieces:
2072            allowed = ', '.join(str(x) for x in expectedPieces)
2073            raise JournalParseError(
2074                f"{entryType} entry had {len(pieces)} arguments but the"
2075                f" allowed argument counts are: {allowed}"
2076            )
2077
2078    def parseOneCommand(
2079        self,
2080        journalText: str,
2081        startIndex: int
2082    ) -> Tuple[List[str], int]:
2083        """
2084        Parses a single command from the given journal text, starting at
2085        the specified start index. Each command occupies a single line,
2086        except when blocks are present in which case it may stretch
2087        across multiple lines. This function splits the command up into a
2088        list of strings (including multi-line strings and/or strings
2089        with spaces in them when blocks are used). It returns that list
2090        of strings, along with the index after the newline at the end of
2091        the command it parsed (which could be used as the start index
2092        for the next command). If the command has no newline after it
2093        (only possible when the string ends) the returned index will be
2094        the length of the string.
2095
2096        If the line starting with the start character is empty (or just
2097        contains spaces), the result will be an empty list along with the
2098        index for the start of the next line.
2099
2100        Examples:
2101
2102        >>> o = JournalObserver()
2103        >>> commands = '''\\
2104        ... S start
2105        ... o option
2106        ...
2107        ... x option next back
2108        ... o lever
2109        ...   e edit [
2110        ...     o bridge
2111        ...       q speed
2112        ...   ] [
2113        ...     o bridge
2114        ...       q X
2115        ...   ]
2116        ... a lever
2117        ... '''
2118        >>> o.parseOneCommand(commands, 0)
2119        (['S', 'start'], 8)
2120        >>> o.parseOneCommand(commands, 8)
2121        (['o', 'option'], 17)
2122        >>> o.parseOneCommand(commands, 17)
2123        ([], 18)
2124        >>> o.parseOneCommand(commands, 18)
2125        (['x', 'option', 'next', 'back'], 37)
2126        >>> o.parseOneCommand(commands, 37)
2127        (['o', 'lever'], 45)
2128        >>> bits, end = o.parseOneCommand(commands, 45)
2129        >>> bits[:2]
2130        ['e', 'edit']
2131        >>> bits[2]
2132        'o bridge\\n      q speed'
2133        >>> bits[3]
2134        'o bridge\\n      q X'
2135        >>> len(bits)
2136        4
2137        >>> end
2138        116
2139        >>> o.parseOneCommand(commands, end)
2140        (['a', 'lever'], 124)
2141
2142        >>> o = JournalObserver()
2143        >>> s = "o up Attic down\\nx up\\no vent\\nq crawl"
2144        >>> o.parseOneCommand(s, 0)
2145        (['o', 'up', 'Attic', 'down'], 16)
2146        >>> o.parseOneCommand(s, 16)
2147        (['x', 'up'], 21)
2148        >>> o.parseOneCommand(s, 21)
2149        (['o', 'vent'], 28)
2150        >>> o.parseOneCommand(s, 28)
2151        (['q', 'crawl'], 35)
2152        """
2153
2154        index = startIndex
2155        unit: Optional[str] = None
2156        bits: List[str] = []
2157        pf = self.parseFormat  # shortcut variable
2158        while index < len(journalText):
2159            char = journalText[index]
2160            if char.isspace():
2161                # Space after non-spaces -> end of unit
2162                if unit is not None:
2163                    bits.append(unit)
2164                    unit = None
2165                # End of line -> end of command
2166                if char == '\n':
2167                    index += 1
2168                    break
2169            else:
2170                # Non-space -> check for block
2171                if char == pf.blockStart:
2172                    if unit is not None:
2173                        bits.append(unit)
2174                        unit = None
2175                    blockEnd = pf.findBlockEnd(journalText, index)
2176                    block = journalText[index + 1:blockEnd - 1].strip()
2177                    bits.append(block)
2178                    index = blockEnd  # +1 added below
2179                elif unit is None:  # Initial non-space -> start of unit
2180                    unit = char
2181                else:  # Continuing non-space -> accumulate
2182                    unit += char
2183            # Increment index
2184            index += 1
2185
2186        # Grab final unit if there is one hanging
2187        if unit is not None:
2188            bits.append(unit)
2189
2190        return (bits, index)
2191
2192    def warn(self, message: str) -> None:
2193        """
2194        Issues a `JournalParseWarning`.
2195        """
2196        if len(self.journalTexts) == 0 or len(self.parseIndices) == 0:
2197            warnings.warn(message, JournalParseWarning)
2198        else:
2199            # Note: We use the basal position info because that will
2200            # typically be much more useful when debugging
2201            ec = errorContext(self.journalTexts[0], self.parseIndices[0])
2202            errorCM = textwrap.indent(errorContextMessage(ec), '  ')
2203            warnings.warn(errorCM + '\n' + message, JournalParseWarning)
2204
2205    def observe(self, journalText: str) -> None:
2206        """
2207        Ingests one or more journal blocks in text format (as a
2208        multi-line string) and updates the exploration being built by
2209        this observer, as well as updating internal state.
2210
2211        This method can be called multiple times to process a longer
2212        journal incrementally including line-by-line.
2213
2214        The `journalText` and `parseIndex` fields will be updated during
2215        parsing to support contextual error messages and warnings.
2216
2217        ## Example:
2218
2219        >>> obs = JournalObserver()
2220        >>> oldWarn = core.WARN_OF_NAME_COLLISIONS
2221        >>> try:
2222        ...     obs.observe('''\\
2223        ... S Room1::start
2224        ... zz Region
2225        ... o nope
2226        ...   q power|tokens*3
2227        ... o unexplored
2228        ... o onwards
2229        ... x onwards sub_room backwards
2230        ... t backwards
2231        ... o down
2232        ...
2233        ... x down Room2::middle up
2234        ... a box
2235        ...   At deactivate
2236        ...   At gain tokens*1
2237        ... o left
2238        ... o right
2239        ...   gt blue
2240        ...
2241        ... x right Room3::middle left
2242        ... o right
2243        ... a miniboss
2244        ...   At deactivate
2245        ...   At gain power
2246        ... x right - left
2247        ... o ledge
2248        ...   q tall
2249        ... t left
2250        ... t left
2251        ... t up
2252        ...
2253        ... x nope secret back
2254        ... ''')
2255        ... finally:
2256        ...     core.WARN_OF_NAME_COLLISIONS = oldWarn
2257        >>> e = obs.getExploration()
2258        >>> len(e)
2259        13
2260        >>> g = e.getSituation().graph
2261        >>> len(g)
2262        9
2263        >>> def showDestinations(g, r):
2264        ...     if isinstance(r, str):
2265        ...         r = obs.parseFormat.parseDecisionSpecifier(r)
2266        ...     d = g.destinationsFrom(r)
2267        ...     for outgoing in sorted(d):
2268        ...         req = g.getTransitionRequirement(r, outgoing)
2269        ...         if req is None or req == base.ReqNothing():
2270        ...             req = ''
2271        ...         else:
2272        ...             req = ' ' + repr(req)
2273        ...         print(outgoing, g.identityOf(d[outgoing]) + req)
2274        ...
2275        >>> "start" in g
2276        False
2277        >>> showDestinations(g, "Room1::start")
2278        down 4 (Room2::middle)
2279        nope 1 (Room1::secret) ReqAny([ReqCapability('power'),\
2280 ReqTokens('tokens', 3)])
2281        onwards 3 (Room1::sub_room)
2282        unexplored 2 (_u.1)
2283        >>> showDestinations(g, "Room1::secret")
2284        back 0 (Room1::start)
2285        >>> showDestinations(g, "Room1::sub_room")
2286        backwards 0 (Room1::start)
2287        >>> showDestinations(g, "Room2::middle")
2288        box 4 (Room2::middle)
2289        left 5 (_u.4)
2290        right 6 (Room3::middle)
2291        up 0 (Room1::start)
2292        >>> g.transitionTags(4, "right")
2293        {'blue': 1}
2294        >>> showDestinations(g, "Room3::middle")
2295        left 4 (Room2::middle)
2296        miniboss 6 (Room3::middle)
2297        right 7 (Room3::-)
2298        >>> showDestinations(g, "Room3::-")
2299        ledge 8 (_u.7) ReqCapability('tall')
2300        left 6 (Room3::middle)
2301        >>> showDestinations(g, "_u.7")
2302        return 7 (Room3::-)
2303        >>> e.getActiveDecisions()
2304        {1}
2305        >>> g.identityOf(1)
2306        '1 (Room1::secret)'
2307
2308        Note that there are plenty of other annotations not shown in
2309        this example; see `DEFAULT_FORMAT` for the default mapping from
2310        journal entry types to markers, and see `JournalEntryType` for
2311        the explanation for each entry type.
2312
2313        Most entries start with a marker (which includes one character
2314        for the type and possibly one for the target) followed by a
2315        single space, and everything after that is the content of the
2316        entry.
2317        """
2318        # Normalize newlines
2319        journalText = journalText\
2320            .replace('\r\n', '\n')\
2321            .replace('\n\r', '\n')\
2322            .replace('\r', '\n')
2323
2324        # Shortcut variable
2325        pf = self.parseFormat
2326
2327        # Remove comments from entire text
2328        journalText = pf.removeComments(journalText)
2329
2330        # TODO: Give access to comments in error messages?
2331        # Store for error messages
2332        self.journalTexts.append(journalText)
2333        self.parseIndices.append(0)
2334
2335        startAt = 0
2336        try:
2337            while startAt < len(journalText):
2338                self.parseIndices[-1] = startAt
2339                bits, startAt = self.parseOneCommand(journalText, startAt)
2340
2341                if len(bits) == 0:
2342                    continue
2343
2344                eType, dType, eTarget, eParts = pf.determineEntryType(bits)
2345                if eType == 'preference':
2346                    self.checkFormat(
2347                        'preference',
2348                        dType,
2349                        eTarget,
2350                        eParts,
2351                        None,
2352                        2
2353                    )
2354                    pref = eParts[0]
2355                    opAnn = get_type_hints(ObservationPreferences)
2356                    if pref not in opAnn:
2357                        raise JournalParseError(
2358                            f"Invalid preference name {pref!r}."
2359                        )
2360
2361                    prefVal: Union[None, str, bool, Set[str]]
2362                    if opAnn[pref] is bool:
2363                        prefVal = pf.onOff(eParts[1])
2364                        if prefVal is None:
2365                            self.warn(
2366                                f"On/off value {eParts[1]!r} is neither"
2367                                f" {pf.markerFor('on')!r} nor"
2368                                f" {pf.markerFor('off')!r}. Assuming"
2369                                f" 'off'."
2370                            )
2371                    elif opAnn[pref] == Set[str]:
2372                        prefVal = set(' '.join(eParts[1:]).split())
2373                    else:  # we assume it's a string
2374                        assert opAnn[pref] is str
2375                        prefVal = eParts[1]
2376
2377                    # Set the preference value (type checked above)
2378                    self.preferences[pref] = prefVal  # type: ignore [literal-required] # noqa: E501
2379
2380                elif eType == 'alias':
2381                    self.checkFormat(
2382                        "alias",
2383                        dType,
2384                        eTarget,
2385                        eParts,
2386                        None,
2387                        None
2388                    )
2389
2390                    if len(eParts) < 2:
2391                        raise JournalParseError(
2392                            "Alias entry must include at least an alias"
2393                            " name and a commands list."
2394                        )
2395                    aliasName = eParts[0]
2396                    parameters = eParts[1:-1]
2397                    commands = eParts[-1]
2398                    self.defineAlias(aliasName, parameters, commands)
2399
2400                elif eType == 'custom':
2401                    self.checkFormat(
2402                        "custom",
2403                        dType,
2404                        eTarget,
2405                        eParts,
2406                        None,
2407                        None
2408                    )
2409                    if len(eParts) == 0:
2410                        raise JournalParseError(
2411                            "Custom entry must include at least an alias"
2412                            " name."
2413                        )
2414                    self.deployAlias(eParts[0], eParts[1:])
2415
2416                elif eType == 'DEBUG':
2417                    self.checkFormat(
2418                        "DEBUG",
2419                        dType,
2420                        eTarget,
2421                        eParts,
2422                        None,
2423                        {1, 2}
2424                    )
2425                    if eParts[0] not in get_args(DebugAction):
2426                        raise JournalParseError(
2427                            f"Invalid debug action: {eParts[0]!r}"
2428                        )
2429                    dAction = cast(DebugAction, eParts[0])
2430                    if len(eParts) > 1:
2431                        self.doDebug(dAction, eParts[1])
2432                    else:
2433                        self.doDebug(dAction)
2434
2435                elif eType == 'START':
2436                    self.checkFormat(
2437                        "START",
2438                        dType,
2439                        eTarget,
2440                        eParts,
2441                        None,
2442                        1
2443                    )
2444
2445                    where = pf.parseDecisionSpecifier(eParts[0])
2446                    if isinstance(where, base.DecisionID):
2447                        raise JournalParseError(
2448                            f"Can't use {repr(where)} as a start"
2449                            f" because the start must be a decision"
2450                            f" name, not a decision ID."
2451                        )
2452                    self.recordStart(where, dType)
2453
2454                elif eType == 'explore':
2455                    self.checkFormat(
2456                        "explore",
2457                        dType,
2458                        eTarget,
2459                        eParts,
2460                        None,
2461                        {1, 2, 3}
2462                    )
2463
2464                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2465
2466                    if len(eParts) == 1:
2467                        self.recordExplore(tr, decisionType=dType)
2468                    elif len(eParts) == 2:
2469                        destination = pf.parseDecisionSpecifier(eParts[1])
2470                        self.recordExplore(
2471                            tr,
2472                            destination,
2473                            decisionType=dType
2474                        )
2475                    else:
2476                        destination = pf.parseDecisionSpecifier(eParts[1])
2477                        self.recordExplore(
2478                            tr,
2479                            destination,
2480                            eParts[2],
2481                            decisionType=dType
2482                        )
2483
2484                elif eType == 'return':
2485                    self.checkFormat(
2486                        "return",
2487                        dType,
2488                        eTarget,
2489                        eParts,
2490                        None,
2491                        {1, 2, 3}
2492                    )
2493                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2494                    if len(eParts) > 1:
2495                        destination = pf.parseDecisionSpecifier(eParts[1])
2496                    else:
2497                        destination = None
2498                    if len(eParts) > 2:
2499                        reciprocal = eParts[2]
2500                    else:
2501                        reciprocal = None
2502                    self.recordReturn(
2503                        tr,
2504                        destination,
2505                        reciprocal,
2506                        decisionType=dType
2507                    )
2508
2509                elif eType == 'action':
2510                    self.checkFormat(
2511                        "action",
2512                        dType,
2513                        eTarget,
2514                        eParts,
2515                        None,
2516                        1
2517                    )
2518                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2519                    self.recordAction(tr, decisionType=dType)
2520
2521                elif eType == 'retrace':
2522                    self.checkFormat(
2523                        "retrace",
2524                        dType,
2525                        eTarget,
2526                        eParts,
2527                        (None, 'actionPart'),
2528                        1
2529                    )
2530                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2531                    self.recordRetrace(
2532                        tr,
2533                        decisionType=dType,
2534                        isAction=eTarget == 'actionPart'
2535                    )
2536
2537                elif eType == 'warp':
2538                    self.checkFormat(
2539                        "warp",
2540                        dType,
2541                        eTarget,
2542                        eParts,
2543                        None,
2544                        {1}
2545                    )
2546
2547                    destination = pf.parseDecisionSpecifier(eParts[0])
2548                    self.recordWarp(destination, decisionType=dType)
2549
2550                elif eType == 'wait':
2551                    self.checkFormat(
2552                        "wait",
2553                        dType,
2554                        eTarget,
2555                        eParts,
2556                        None,
2557                        0
2558                    )
2559                    self.recordWait(decisionType=dType)
2560
2561                elif eType == 'observe':
2562                    self.checkFormat(
2563                        "observe",
2564                        dType,
2565                        eTarget,
2566                        eParts,
2567                        (None, 'actionPart', 'endingPart'),
2568                        (1, 2, 3)
2569                    )
2570                    if eTarget is None:
2571                        self.recordObserve(*eParts)
2572                    elif eTarget == 'actionPart':
2573                        if len(eParts) > 1:
2574                            raise JournalParseError(
2575                                f"Observing action {eParts[0]!r} at"
2576                                f" {self.definiteDecisionTarget()!r}:"
2577                                f" neither a destination nor a"
2578                                f" reciprocal may be specified when"
2579                                f" observing an action (did you mean to"
2580                                f" observe a transition?)."
2581                            )
2582                        self.recordObserveAction(*eParts)
2583                    elif eTarget == 'endingPart':
2584                        if len(eParts) > 1:
2585                            raise JournalParseError(
2586                                f"Observing ending {eParts[0]!r} at"
2587                                f" {self.definiteDecisionTarget()!r}:"
2588                                f" neither a destination nor a"
2589                                f" reciprocal may be specified when"
2590                                f" observing an ending (did you mean to"
2591                                f" observe a transition?)."
2592                            )
2593                        self.recordObserveEnding(*eParts)
2594
2595                elif eType == 'END':
2596                    self.checkFormat(
2597                        "END",
2598                        dType,
2599                        eTarget,
2600                        eParts,
2601                        (None, 'actionPart'),
2602                        1
2603                    )
2604                    self.recordEnd(
2605                        eParts[0],
2606                        eTarget == 'actionPart',
2607                        decisionType=dType
2608                    )
2609
2610                elif eType == 'mechanism':
2611                    self.checkFormat(
2612                        "mechanism",
2613                        dType,
2614                        eTarget,
2615                        eParts,
2616                        None,
2617                        1
2618                    )
2619                    mReq = pf.parseRequirement(eParts[0])
2620                    if (
2621                        not isinstance(mReq, base.ReqMechanism)
2622                     or not isinstance(
2623                            mReq.mechanism,
2624                            (base.MechanismName, base.MechanismSpecifier)
2625                        )
2626                    ):
2627                        raise JournalParseError(
2628                            f"Invalid mechanism declaration"
2629                            f" {eParts[0]!r}. Declaration must specify"
2630                            f" mechanism name and starting state."
2631                        )
2632                    mState = mReq.reqState
2633                    if isinstance(mReq.mechanism, base.MechanismName):
2634                        where = self.definiteDecisionTarget()
2635                        mName = mReq.mechanism
2636                    else:
2637                        assert isinstance(
2638                            mReq.mechanism,
2639                            base.MechanismSpecifier
2640                        )
2641                        mSpec = mReq.mechanism
2642                        mName = mSpec.name
2643                        if mSpec.decision is not None:
2644                            where = base.DecisionSpecifier(
2645                                mSpec.domain,
2646                                mSpec.zone,
2647                                mSpec.decision
2648                            )
2649                        else:
2650                            where = self.definiteDecisionTarget()
2651                            graph = self.exploration.getSituation().graph
2652                            thisDomain = graph.domainFor(where)
2653                            theseZones = graph.zoneAncestors(where)
2654                            if (
2655                                mSpec.domain is not None
2656                            and mSpec.domain != thisDomain
2657                            ):
2658                                raise JournalParseError(
2659                                    f"Mechanism specifier {mSpec!r}"
2660                                    f" does not specify a decision but"
2661                                    f" includes domain {mSpec.domain!r}"
2662                                    f" which does not match the domain"
2663                                    f" {thisDomain!r} of the current"
2664                                    f" decision {graph.identityOf(where)}"
2665                                )
2666                            if (
2667                                mSpec.zone is not None
2668                            and mSpec.zone not in theseZones
2669                            ):
2670                                raise JournalParseError(
2671                                    f"Mechanism specifier {mSpec!r}"
2672                                    f" does not specify a decision but"
2673                                    f" includes zone {mSpec.zone!r}"
2674                                    f" which is not one of the zones"
2675                                    f" that the current decision"
2676                                    f" {graph.identityOf(where)} is in:"
2677                                    f"\n{theseZones!r}"
2678                                )
2679                    self.recordMechanism(where, mName, mState)
2680
2681                elif eType == 'requirement':
2682                    self.checkFormat(
2683                        "requirement",
2684                        dType,
2685                        eTarget,
2686                        eParts,
2687                        (None, 'reciprocalPart', 'bothPart'),
2688                        None
2689                    )
2690                    req = pf.parseRequirement(' '.join(eParts))
2691                    if eTarget in (None, 'bothPart'):
2692                        self.recordRequirement(req)
2693                    if eTarget in ('reciprocalPart', 'bothPart'):
2694                        self.recordReciprocalRequirement(req)
2695
2696                elif eType == 'effect':
2697                    self.checkFormat(
2698                        "effect",
2699                        dType,
2700                        eTarget,
2701                        eParts,
2702                        (None, 'reciprocalPart', 'bothPart'),
2703                        None
2704                    )
2705
2706                    consequence: base.Consequence
2707                    try:
2708                        consequence = pf.parseConsequence(' '.join(eParts))
2709                    except parsing.ParseError:
2710                        consequence = [pf.parseEffect(' '.join(eParts))]
2711
2712                    if eTarget in (None, 'bothPart'):
2713                        self.recordTransitionConsequence(consequence)
2714                    if eTarget in ('reciprocalPart', 'bothPart'):
2715                        self.recordReciprocalConsequence(consequence)
2716
2717                elif eType == 'apply':
2718                    self.checkFormat(
2719                        "apply",
2720                        dType,
2721                        eTarget,
2722                        eParts,
2723                        (None, 'transitionPart'),
2724                        None
2725                    )
2726
2727                    toApply: base.Consequence
2728                    try:
2729                        toApply = pf.parseConsequence(' '.join(eParts))
2730                    except parsing.ParseError:
2731                        toApply = [pf.parseEffect(' '.join(eParts))]
2732
2733                    # If we targeted a transition, that means we wanted
2734                    # to both apply the consequence now AND set it up as
2735                    # an consequence of the transition we just took.
2736                    if eTarget == 'transitionPart':
2737                        if self.context['transition'] is None:
2738                            raise JournalParseError(
2739                                "Can't apply a consequence to a"
2740                                " transition here because there is no"
2741                                " current relevant transition."
2742                            )
2743                        # We need to apply these consequences as part of
2744                        # the transition so their trigger count will be
2745                        # tracked properly, but we do not want to
2746                        # re-apply the other parts of the consequence.
2747                        self.recordAdditionalTransitionConsequence(
2748                            toApply
2749                        )
2750                    else:
2751                        # Otherwise just apply the consequence
2752                        self.exploration.applyExtraneousConsequence(
2753                            toApply,
2754                            where=self.context['transition'],
2755                            moveWhich=self.context['focus']
2756                        )
2757                        # Note: no situation-based variables need
2758                        # updating here
2759
2760                elif eType == 'tag':
2761                    self.checkFormat(
2762                        "tag",
2763                        dType,
2764                        eTarget,
2765                        eParts,
2766                        (
2767                            None,
2768                            'decisionPart',
2769                            'transitionPart',
2770                            'reciprocalPart',
2771                            'bothPart',
2772                            'zonePart'
2773                        ),
2774                        None
2775                    )
2776                    tag: base.Tag
2777                    value: base.TagValue
2778                    if len(eParts) == 0:
2779                        raise JournalParseError(
2780                            "tag entry must include at least a tag name."
2781                        )
2782                    elif len(eParts) == 1:
2783                        tag = eParts[0]
2784                        value = 1
2785                    elif len(eParts) == 2:
2786                        tag, value = eParts
2787                        value = pf.parseTagValue(value)
2788                    else:
2789                        raise JournalParseError(
2790                            f"tag entry has too many parts (only a tag"
2791                            f" name and a tag value are allowed). Got:"
2792                            f" {eParts}"
2793                        )
2794
2795                    if eTarget is None:
2796                        self.recordTagStep(tag, value)
2797                    elif eTarget == "decisionPart":
2798                        self.recordTagDecision(tag, value)
2799                    elif eTarget == "transitionPart":
2800                        self.recordTagTranstion(tag, value)
2801                    elif eTarget == "reciprocalPart":
2802                        self.recordTagReciprocal(tag, value)
2803                    elif eTarget == "bothPart":
2804                        self.recordTagTranstion(tag, value)
2805                        self.recordTagReciprocal(tag, value)
2806                    elif eTarget == "zonePart":
2807                        self.recordTagZone(0, tag, value)
2808                    elif (
2809                        isinstance(eTarget, tuple)
2810                    and len(eTarget) == 2
2811                    and eTarget[0] == "zonePart"
2812                    and isinstance(eTarget[1], int)
2813                    ):
2814                        self.recordTagZone(eTarget[1] - 1, tag, value)
2815                    else:
2816                        raise JournalParseError(
2817                            f"Invalid tag target type {eTarget!r}."
2818                        )
2819
2820                elif eType == 'annotate':
2821                    self.checkFormat(
2822                        "annotate",
2823                        dType,
2824                        eTarget,
2825                        eParts,
2826                        (
2827                            None,
2828                            'decisionPart',
2829                            'transitionPart',
2830                            'reciprocalPart',
2831                            'bothPart'
2832                        ),
2833                        None
2834                    )
2835                    if len(eParts) == 0:
2836                        raise JournalParseError(
2837                            "annotation may not be empty."
2838                        )
2839                    combined = ' '.join(eParts)
2840                    if eTarget is None:
2841                        self.recordAnnotateStep(combined)
2842                    elif eTarget == "decisionPart":
2843                        self.recordAnnotateDecision(combined)
2844                    elif eTarget == "transitionPart":
2845                        self.recordAnnotateTranstion(combined)
2846                    elif eTarget == "reciprocalPart":
2847                        self.recordAnnotateReciprocal(combined)
2848                    elif eTarget == "bothPart":
2849                        self.recordAnnotateTranstion(combined)
2850                        self.recordAnnotateReciprocal(combined)
2851                    elif eTarget == "zonePart":
2852                        self.recordAnnotateZone(0, combined)
2853                    elif (
2854                        isinstance(eTarget, tuple)
2855                    and len(eTarget) == 2
2856                    and eTarget[0] == "zonePart"
2857                    and isinstance(eTarget[1], int)
2858                    ):
2859                        self.recordAnnotateZone(eTarget[1] - 1, combined)
2860                    else:
2861                        raise JournalParseError(
2862                            f"Invalid annotation target type {eTarget!r}."
2863                        )
2864
2865                elif eType == 'context':
2866                    self.checkFormat(
2867                        "context",
2868                        dType,
2869                        eTarget,
2870                        eParts,
2871                        None,
2872                        1
2873                    )
2874                    if eParts[0] == pf.markerFor('commonContext'):
2875                        self.recordContextSwap(None)
2876                    else:
2877                        self.recordContextSwap(eParts[0])
2878
2879                elif eType == 'domain':
2880                    self.checkFormat(
2881                        "domain",
2882                        dType,
2883                        eTarget,
2884                        eParts,
2885                        None,
2886                        {1, 2, 3}
2887                    )
2888                    inCommon = False
2889                    if eParts[-1] == pf.markerFor('commonContext'):
2890                        eParts = eParts[:-1]
2891                        inCommon = True
2892                    if len(eParts) == 3:
2893                        raise JournalParseError(
2894                            f"A domain entry may only have 1 or 2"
2895                            f" arguments unless the last argument is"
2896                            f" {repr(pf.markerFor('commonContext'))}"
2897                        )
2898                    elif len(eParts) == 2:
2899                        if eParts[0] == pf.markerFor('exclusiveDomain'):
2900                            self.recordDomainFocus(
2901                                eParts[1],
2902                                exclusive=True,
2903                                inCommon=inCommon
2904                            )
2905                        elif eParts[0] == pf.markerFor('notApplicable'):
2906                            # Deactivate the domain
2907                            self.recordDomainUnfocus(
2908                                eParts[1],
2909                                inCommon=inCommon
2910                            )
2911                        else:
2912                            # Set up new domain w/ given focalization
2913                            focalization = pf.parseFocalization(eParts[1])
2914                            self.recordNewDomain(
2915                                eParts[0],
2916                                focalization,
2917                                inCommon=inCommon
2918                            )
2919                    else:
2920                        # Focus the domain (or possibly create it)
2921                        self.recordDomainFocus(
2922                            eParts[0],
2923                            inCommon=inCommon
2924                        )
2925
2926                elif eType == 'focus':
2927                    self.checkFormat(
2928                        "focus",
2929                        dType,
2930                        eTarget,
2931                        eParts,
2932                        None,
2933                        {1, 2}
2934                    )
2935                    if len(eParts) == 2:  # explicit domain
2936                        self.recordFocusOn(eParts[1], eParts[0])
2937                    else:  # implicit domain
2938                        self.recordFocusOn(eParts[0])
2939
2940                elif eType == 'zone':
2941                    self.checkFormat(
2942                        "zone",
2943                        dType,
2944                        eTarget,
2945                        eParts,
2946                        (None, 'zonePart'),
2947                        1
2948                    )
2949                    if eTarget is None:
2950                        level = 0
2951                    elif eTarget == 'zonePart':
2952                        level = 1
2953                    else:
2954                        assert isinstance(eTarget, tuple)
2955                        assert len(eTarget) == 2
2956                        level = eTarget[1]
2957                    self.recordZone(level, eParts[0])
2958
2959                elif eType == 'unify':
2960                    self.checkFormat(
2961                        "unify",
2962                        dType,
2963                        eTarget,
2964                        eParts,
2965                        (None, 'transitionPart', 'reciprocalPart'),
2966                        (1, 2)
2967                    )
2968                    if eTarget is None:
2969                        decisions = [
2970                            pf.parseDecisionSpecifier(p)
2971                            for p in eParts
2972                        ]
2973                        self.recordUnify(*decisions)
2974                    elif eTarget == 'transitionPart':
2975                        if len(eParts) != 1:
2976                            raise JournalParseError(
2977                                "A transition unification entry may only"
2978                                f" have one argument, but we got"
2979                                f" {len(eParts)}."
2980                            )
2981                        self.recordUnifyTransition(eParts[0])
2982                    elif eTarget == 'reciprocalPart':
2983                        if len(eParts) != 1:
2984                            raise JournalParseError(
2985                                "A transition unification entry may only"
2986                                f" have one argument, but we got"
2987                                f" {len(eParts)}."
2988                            )
2989                        self.recordUnifyReciprocal(eParts[0])
2990                    else:
2991                        raise RuntimeError(
2992                            f"Invalid target type {eTarget} after check"
2993                            f" for unify entry!"
2994                        )
2995
2996                elif eType == 'obviate':
2997                    self.checkFormat(
2998                        "obviate",
2999                        dType,
3000                        eTarget,
3001                        eParts,
3002                        None,
3003                        3
3004                    )
3005                    transition, targetDecision, targetTransition = eParts
3006                    self.recordObviate(
3007                        transition,
3008                        pf.parseDecisionSpecifier(targetDecision),
3009                        targetTransition
3010                    )
3011
3012                elif eType == 'extinguish':
3013                    self.checkFormat(
3014                        "extinguish",
3015                        dType,
3016                        eTarget,
3017                        eParts,
3018                        (
3019                            None,
3020                            'decisionPart',
3021                            'transitionPart',
3022                            'reciprocalPart',
3023                            'bothPart'
3024                        ),
3025                        1
3026                    )
3027                    if eTarget is None:
3028                        eTarget = 'bothPart'
3029                    if eTarget == 'decisionPart':
3030                        self.recordExtinguishDecision(
3031                            pf.parseDecisionSpecifier(eParts[0])
3032                        )
3033                    elif eTarget == 'transitionPart':
3034                        transition = eParts[0]
3035                        here = self.definiteDecisionTarget()
3036                        self.recordExtinguishTransition(
3037                            here,
3038                            transition,
3039                            False
3040                        )
3041                    elif eTarget == 'bothPart':
3042                        transition = eParts[0]
3043                        here = self.definiteDecisionTarget()
3044                        self.recordExtinguishTransition(
3045                            here,
3046                            transition,
3047                            True
3048                        )
3049                    else:  # Must be reciprocalPart
3050                        transition = eParts[0]
3051                        here = self.definiteDecisionTarget()
3052                        now = self.exploration.getSituation()
3053                        rPair = now.graph.getReciprocalPair(here, transition)
3054                        if rPair is None:
3055                            raise JournalParseError(
3056                                f"Attempted to extinguish the"
3057                                f" reciprocal of transition"
3058                                f" {transition!r} which "
3059                                f" has no reciprocal (or which"
3060                                f" doesn't exist from decision"
3061                                f" {now.graph.identityOf(here)})."
3062                            )
3063
3064                        self.recordExtinguishTransition(
3065                            rPair[0],
3066                            rPair[1],
3067                            deleteReciprocal=False
3068                        )
3069
3070                elif eType == 'complicate':
3071                    self.checkFormat(
3072                        "complicate",
3073                        dType,
3074                        eTarget,
3075                        eParts,
3076                        None,
3077                        4
3078                    )
3079                    target, newName, newReciprocal, newRR = eParts
3080                    self.recordComplicate(
3081                        target,
3082                        newName,
3083                        newReciprocal,
3084                        newRR
3085                    )
3086
3087                elif eType == 'status':
3088                    self.checkFormat(
3089                        "status",
3090                        dType,
3091                        eTarget,
3092                        eParts,
3093                        (None, 'unfinishedPart'),
3094                        {0, 1}
3095                    )
3096                    dID = self.definiteDecisionTarget()
3097                    # Default status to use
3098                    status: base.ExplorationStatus = 'explored'
3099                    # Figure out whether a valid status was provided
3100                    if len(eParts) > 0:
3101                        assert len(eParts) == 1
3102                        eArgs = get_args(base.ExplorationStatus)
3103                        if eParts[0] not in eArgs:
3104                            raise JournalParseError(
3105                                f"Invalid explicit exploration status"
3106                                f" {eParts[0]!r}. Exploration statuses"
3107                                f" must be one of:\n{eArgs!r}"
3108                            )
3109                        status = cast(base.ExplorationStatus, eParts[0])
3110                    # Record new status, as long as we have an explicit
3111                    # status OR 'unfinishedPart' was not given. If
3112                    # 'unfinishedPart' was given, also block auto updates
3113                    if eTarget == 'unfinishedPart':
3114                        if len(eParts) > 0:
3115                            self.recordStatus(dID, status)
3116                        self.recordObservationIncomplete(dID)
3117                    else:
3118                        self.recordStatus(dID, status)
3119
3120                elif eType == 'revert':
3121                    self.checkFormat(
3122                        "revert",
3123                        dType,
3124                        eTarget,
3125                        eParts,
3126                        None,
3127                        None
3128                    )
3129                    aspects: List[str]
3130                    if len(eParts) == 0:
3131                        slot = base.DEFAULT_SAVE_SLOT
3132                        aspects = []
3133                    else:
3134                        slot = eParts[0]
3135                        aspects = eParts[1:]
3136                    aspectsSet = set(aspects)
3137                    if len(aspectsSet) == 0:
3138                        aspectsSet = self.preferences['revertAspects']
3139                    self.recordRevert(slot, aspectsSet, decisionType=dType)
3140
3141                elif eType == 'fulfills':
3142                    self.checkFormat(
3143                        "fulfills",
3144                        dType,
3145                        eTarget,
3146                        eParts,
3147                        None,
3148                        2
3149                    )
3150                    condition = pf.parseRequirement(eParts[0])
3151                    fReq = pf.parseRequirement(eParts[1])
3152                    fulfills: Union[
3153                        base.Capability,
3154                        Tuple[base.MechanismID, base.MechanismState]
3155                    ]
3156                    if isinstance(fReq, base.ReqCapability):
3157                        fulfills = fReq.capability
3158                    elif isinstance(fReq, base.ReqMechanism):
3159                        mState = fReq.reqState
3160                        if isinstance(fReq.mechanism, int):
3161                            mID = fReq.mechanism
3162                        else:
3163                            graph = self.exploration.getSituation().graph
3164                            mID = graph.resolveMechanism(
3165                                fReq.mechanism,
3166                                {self.definiteDecisionTarget()}
3167                            )
3168                        fulfills = (mID, mState)
3169                    else:
3170                        raise JournalParseError(
3171                            f"Cannot fulfill {eParts[1]!r} because it"
3172                            f" doesn't specify either a capability or a"
3173                            f" mechanism/state pair."
3174                        )
3175                    self.recordFulfills(condition, fulfills)
3176
3177                elif eType == 'relative':
3178                    self.checkFormat(
3179                        "relative",
3180                        dType,
3181                        eTarget,
3182                        eParts,
3183                        (None, 'transitionPart'),
3184                        (0, 1, 2)
3185                    )
3186                    if (
3187                        len(eParts) == 1
3188                    and eParts[0] == self.parseFormat.markerFor(
3189                            'relative'
3190                        )
3191                    ):
3192                        self.relative()
3193                    elif eTarget == 'transitionPart':
3194                        self.relative(None, *eParts)
3195                    else:
3196                        self.relative(*eParts)
3197
3198                else:
3199                    raise NotImplementedError(
3200                        f"Unrecognized event type {eType!r}."
3201                    )
3202        except Exception as e:
3203            raise LocatedJournalParseError(
3204                journalText,
3205                self.parseIndices[-1],
3206                e
3207            )
3208        finally:
3209            self.journalTexts.pop()
3210            self.parseIndices.pop()
3211
3212    def defineAlias(
3213        self,
3214        name: str,
3215        parameters: Sequence[str],
3216        commands: str
3217    ) -> None:
3218        """
3219        Defines an alias: a block of commands that can be played back
3220        later using the 'custom' command, with parameter substitutions.
3221
3222        If an alias with the specified name already existed, it will be
3223        replaced.
3224
3225        Each of the listed parameters must be supplied when invoking the
3226        alias, and where they appear within curly braces in the commands
3227        string, they will be substituted in. Additional names starting
3228        with '_' plus an optional integer will also be substituted with
3229        unique names (see `nextUniqueName`), with the same name being
3230        used for every instance that shares the same numerical suffix
3231        within each application of the command. Substitution points must
3232        not include spaces; if an open curly brace is followed by
3233        whitesapce or where a close curly brace is proceeded by
3234        whitespace, those will be treated as normal curly braces and will
3235        not create a substitution point.
3236
3237        For example:
3238
3239        >>> o = JournalObserver()
3240        >>> o.defineAlias(
3241        ...     'hintRoom',
3242        ...     ['name'],
3243        ...     'o {_5}\\nx {_5} {name} {_5}\\ngd hint\\nt {_5}'
3244        ... )  # _5 to show that the suffix doesn't matter if it's consistent
3245        >>> o.defineAlias(
3246        ...     'trade',
3247        ...     ['gain', 'lose'],
3248        ...     'A { gain {gain}; lose {lose} }'
3249        ... )  # note outer curly braces
3250        >>> o.recordStart('start')
3251        >>> o.deployAlias('hintRoom', ['hint1'])
3252        >>> o.deployAlias('hintRoom', ['hint2'])
3253        >>> o.deployAlias('trade', ['flower*1', 'coin*1'])
3254        >>> e = o.getExploration()
3255        >>> e.movementAtStep(0)
3256        (None, None, 0)
3257        >>> e.movementAtStep(1)
3258        (0, '_0', 1)
3259        >>> e.movementAtStep(2)
3260        (1, '_0', 0)
3261        >>> e.movementAtStep(3)
3262        (0, '_1', 2)
3263        >>> e.movementAtStep(4)
3264        (2, '_1', 0)
3265        >>> g = e.getSituation().graph
3266        >>> len(g)
3267        3
3268        >>> g.namesListing([0, 1, 2])
3269        '  0 (start)\\n  1 (hint1)\\n  2 (hint2)\\n'
3270        >>> g.decisionTags('hint1')
3271        {'hint': 1}
3272        >>> g.decisionTags('hint2')
3273        {'hint': 1}
3274        >>> e.tokenCountNow('coin')
3275        -1
3276        >>> e.tokenCountNow('flower')
3277        1
3278        """
3279        # Going to be formatted twice so {{{{ -> {{ -> {
3280        # TODO: Move this logic into deployAlias
3281        commands = re.sub(r'{(\s)', r'{{{{\1', commands)
3282        commands = re.sub(r'(\s)}', r'\1}}}}', commands)
3283        self.aliases[name] = (list(parameters), commands)
3284
3285    def deployAlias(self, name: str, arguments: Sequence[str]) -> None:
3286        """
3287        Deploys an alias, taking its command string and substituting in
3288        the provided argument values for each of the alias' parameters,
3289        plus any unique names that it requests. Substitution happens
3290        first for named arguments and then for unique strings, so named
3291        arguments of the form '{_-n-}' where -n- is an integer will end
3292        up being substituted for unique names. Sets of curly braces that
3293        have at least one space immediately after the open brace or
3294        immediately before the closing brace will be interpreted as
3295        normal curly braces, NOT as the start/end of a substitution
3296        point.
3297
3298        There are a few automatic arguments (although these can be
3299        overridden if the alias definition uses the same argument name
3300        explicitly):
3301        - '__here__' will substitute to the ID of the current decision
3302            based on the `ObservationContext`, or will generate an error
3303            if there is none. This is the current decision at the moment
3304            the alias is deployed, NOT based on steps within the alias up
3305            to the substitution point.
3306        - '__hereName__' will substitute the name of the current
3307            decision.
3308        - '__zone__' will substitute the name of the alphabetically
3309            first level-0 zone ancestor of the current decision.
3310        - '__region__' will substitute the name of the alphabetically
3311            first level-1 zone ancestor of the current decision.
3312        - '__transition__' will substitute to the name of the current
3313            transition, or will generate an error if there is none. Note
3314            that the current transition is sometimes NOT a valid
3315            transition from the current decision, because when you take
3316            a transition, that transition's name is current but the
3317            current decision is its destination.
3318        - '__reciprocal__' will substitute to the name of the reciprocal
3319            of the current transition.
3320        - '__trBase__' will substitute to the decision from which the
3321            current transition departs.
3322        - '__trDest__' will substitute to the destination of the current
3323            transition.
3324        - '__prev__' will substitute to the ID of the primary decision in
3325            the previous exploration step, (which is NOT always the
3326            previous current decision of the `ObservationContext`,
3327            especially in relative mode).
3328        - '__across__-name-__' where '-name-' is a transition name will
3329            substitute to the decision reached by traversing that
3330            transition from the '__here__' decision. Note that the
3331            transition name used must be a valid Python identifier.
3332
3333        Raises a `JournalParseError` if the specified alias does not
3334        exist, or if the wrong number of parameters has been supplied.
3335
3336        See `defineAlias` for an example.
3337        """
3338        # Fetch the alias
3339        alias = self.aliases.get(name)
3340        if alias is None:
3341            raise JournalParseError(
3342                f"Alias {name!r} has not been defined yet."
3343            )
3344        paramNames, commands = alias
3345
3346        # Check arguments
3347        arguments = list(arguments)
3348        if len(arguments) != len(paramNames):
3349            raise JournalParseError(
3350                f"Alias {name!r} requires {len(paramNames)} parameters,"
3351                f" but you supplied {len(arguments)}."
3352            )
3353
3354        # Find unique names
3355        uniques = set([
3356            match.strip('{}')
3357            for match in re.findall('{_[0-9]*}', commands)
3358        ])
3359
3360        # Build substitution dictionary that passes through uniques
3361        firstWave = {unique: '{' + unique + '}' for unique in uniques}
3362
3363        # Fill in each non-overridden & requested auto variable:
3364        graph = self.exploration.getSituation().graph
3365        if '{__here__}' in commands and '__here__' not in firstWave:
3366            firstWave['__here__'] = self.definiteDecisionTarget()
3367        if '{__hereName__}' in commands and '__hereName__' not in firstWave:
3368            firstWave['__hereName__'] = graph.nameFor(
3369                self.definiteDecisionTarget()
3370            )
3371        if '{__zone__}' in commands and '__zone__' not in firstWave:
3372            baseDecision = self.definiteDecisionTarget()
3373            parents = sorted(
3374                ancestor
3375                for ancestor in graph.zoneAncestors(baseDecision)
3376                if graph.zoneHierarchyLevel(ancestor) == 0
3377            )
3378            if len(parents) == 0:
3379                raise JournalParseError(
3380                    f"Used __zone__ in a macro, but the current"
3381                    f" decision {graph.identityOf(baseDecision)} is not"
3382                    f" in any level-0 zones."
3383                )
3384            firstWave['__zone__'] = parents[0]
3385        if '{__region__}' in commands and '__region__' not in firstWave:
3386            baseDecision = self.definiteDecisionTarget()
3387            grandparents = sorted(
3388                ancestor
3389                for ancestor in graph.zoneAncestors(baseDecision)
3390                if graph.zoneHierarchyLevel(ancestor) == 1
3391            )
3392            if len(grandparents) == 0:
3393                raise JournalParseError(
3394                    f"Used __region__ in a macro, but the current"
3395                    f" decision {graph.identityOf(baseDecision)} is not"
3396                    f" in any level-1 zones."
3397                )
3398            firstWave['__region__'] = grandparents[0]
3399        if (
3400            '{__transition__}' in commands
3401        and '__transition__' not in firstWave
3402        ):
3403            ctxTr = self.currentTransitionTarget()
3404            if ctxTr is None:
3405                raise JournalParseError(
3406                    f"Can't deploy alias {name!r} because it has a"
3407                    f" __transition__ auto-slot but there is no current"
3408                    f" transition at the current exploration step."
3409                )
3410            firstWave['__transition__'] = ctxTr[1]
3411        if '{__trBase__}' in commands and '__trBase__' not in firstWave:
3412            ctxTr = self.currentTransitionTarget()
3413            if ctxTr is None:
3414                raise JournalParseError(
3415                    f"Can't deploy alias {name!r} because it has a"
3416                    f" __transition__ auto-slot but there is no current"
3417                    f" transition at the current exploration step."
3418                )
3419            firstWave['__trBase__'] = ctxTr[0]
3420        if '{__trDest__}' in commands and '__trDest__' not in firstWave:
3421            ctxTr = self.currentTransitionTarget()
3422            if ctxTr is None:
3423                raise JournalParseError(
3424                    f"Can't deploy alias {name!r} because it has a"
3425                    f" __transition__ auto-slot but there is no current"
3426                    f" transition at the current exploration step."
3427                )
3428            firstWave['__trDest__'] = graph.getDestination(*ctxTr)
3429        if (
3430            '{__reciprocal__}' in commands
3431        and '__reciprocal__' not in firstWave
3432        ):
3433            ctxTr = self.currentTransitionTarget()
3434            if ctxTr is None:
3435                raise JournalParseError(
3436                    f"Can't deploy alias {name!r} because it has a"
3437                    f" __transition__ auto-slot but there is no current"
3438                    f" transition at the current exploration step."
3439                )
3440            firstWave['__reciprocal__'] = graph.getReciprocal(*ctxTr)
3441        if '{__prev__}' in commands and '__prev__' not in firstWave:
3442            try:
3443                prevPrimary = self.exploration.primaryDecision(-2)
3444            except IndexError:
3445                raise JournalParseError(
3446                    f"Can't deploy alias {name!r} because it has a"
3447                    f" __prev__ auto-slot but there is no previous"
3448                    f" exploration step."
3449                )
3450            if prevPrimary is None:
3451                raise JournalParseError(
3452                    f"Can't deploy alias {name!r} because it has a"
3453                    f" __prev__ auto-slot but there is no primary"
3454                    f" decision for the previous exploration step."
3455                )
3456            firstWave['__prev__'] = prevPrimary
3457
3458        here = self.currentDecisionTarget()
3459        for match in re.findall(r'{__across__[^ ]\+__}', commands):
3460            if here is None:
3461                raise JournalParseError(
3462                    f"Can't deploy alias {name!r} because it has an"
3463                    f" __across__ auto-slot but there is no current"
3464                    f" decision."
3465                )
3466            transition = match[11:-3]
3467            dest = graph.getDestination(here, transition)
3468            firstWave[f'__across__{transition}__'] = dest
3469        firstWave.update({
3470            param: value
3471            for (param, value) in zip(paramNames, arguments)
3472        })
3473
3474        # Substitute parameter values
3475        commands = commands.format(**firstWave)
3476
3477        uniques = set([
3478            match.strip('{}')
3479            for match in re.findall('{_[0-9]*}', commands)
3480        ])
3481
3482        # Substitute for remaining unique names
3483        uniqueValues = {
3484            unique: self.nextUniqueName()
3485            for unique in sorted(uniques)  # sort for stability
3486        }
3487        commands = commands.format(**uniqueValues)
3488
3489        # Now run the commands
3490        self.observe(commands)
3491
3492    def doDebug(self, action: DebugAction, arg: str = "") -> None:
3493        """
3494        Prints out a debugging message to stderr. Useful for figuring
3495        out parsing errors. See also `DebugAction` and
3496        `JournalEntryType. Certain actions allow an extra argument. The
3497        action will be one of:
3498        - 'here': prints the ID and name of the current decision, or
3499            `None` if there isn't one.
3500        - 'transition': prints the name of the current transition, or `None`
3501            if there isn't one.
3502        - 'destinations': prints the ID and name of the current decision,
3503            followed by the names of each outgoing transition and their
3504            destinations. Includes any requirements the transitions have.
3505            If an extra argument is supplied, looks up that decision and
3506            prints destinations from there.
3507        - 'steps': prints out the number of steps in the current exploration,
3508            plus the number since the most recent use of 'steps'.
3509        - 'decisions': prints out the number of decisions in the current
3510            graph, plus the number added/removed since the most recent use of
3511            'decisions'.
3512        - 'active': prints out the names listing of all currently active
3513            decisions.
3514        - 'primary': prints out the identity of the current primary
3515            decision, or None if there is none.
3516        - 'saved': prints out the primary decision for the state saved in
3517            the default save slot, or for a specific save slot if a
3518            second argument is given.
3519        - 'inventory': Displays all current capabilities, tokens, and
3520            skills.
3521        - 'mechanisms': Displays all current mechanisms and their states.
3522        - 'equivalences': Displays all current equivalences, along with
3523            whether or not they're active.
3524        """
3525        graph = self.exploration.getSituation().graph
3526        if arg != '' and action not in ('destinations', 'saved'):
3527            raise JournalParseError(
3528                f"Invalid debug command {action!r} with arg {arg!r}:"
3529                f" Only 'destination' and 'saved' actions may include a"
3530                f" second argument."
3531            )
3532        if action == "here":
3533            dt = self.currentDecisionTarget()
3534            print(
3535                f"Current decision is: {graph.identityOf(dt)}",
3536                file=sys.stderr
3537            )
3538        elif action == "transition":
3539            tTarget = self.currentTransitionTarget()
3540            if tTarget is None:
3541                print("Current transition is: None", file=sys.stderr)
3542            else:
3543                tDecision, tTransition = tTarget
3544                print(
3545                    (
3546                        f"Current transition is {tTransition!r} from"
3547                        f" {graph.identityOf(tDecision)}."
3548                    ),
3549                    file=sys.stderr
3550                )
3551        elif action == "destinations":
3552            if arg == "":
3553                here = self.currentDecisionTarget()
3554                adjective = "current"
3555                if here is None:
3556                    print("There is no current decision.", file=sys.stderr)
3557            else:
3558                adjective = "target"
3559                dHint = None
3560                zHint = None
3561                tSpec = self.decisionTargetSpecifier()
3562                if tSpec is not None:
3563                    dHint = tSpec.domain
3564                    zHint = tSpec.zone
3565                here = self.exploration.getSituation().graph.getDecision(
3566                    self.parseFormat.parseDecisionSpecifier(arg),
3567                    zoneHint=zHint,
3568                    domainHint=dHint,
3569                )
3570                if here is None:
3571                    print("Decision {arg!r} was not found.", file=sys.stderr)
3572
3573            if here is not None:
3574                dests = graph.destinationsFrom(here)
3575                outgoing = {
3576                    route: dests[route]
3577                    for route in dests
3578                    if dests[route] != here
3579                }
3580                actions = {
3581                    route: dests[route]
3582                    for route in dests
3583                    if dests[route] == here
3584                }
3585                print(
3586                    f"The {adjective} decision is: {graph.identityOf(here)}",
3587                    file=sys.stderr
3588                )
3589                if len(outgoing) == 0:
3590                    print(
3591                        (
3592                            "There are no outgoing transitions at this"
3593                            " decision."
3594                        ),
3595                        file=sys.stderr
3596                    )
3597                else:
3598                    print(
3599                        (
3600                            f"There are {len(outgoing)} outgoing"
3601                            f" transition(s):"
3602                        ),
3603                        file=sys.stderr
3604                    )
3605                for transition in outgoing:
3606                    destination = outgoing[transition]
3607                    req = graph.getTransitionRequirement(
3608                        here,
3609                        transition
3610                    )
3611                    rstring = ''
3612                    if req != base.ReqNothing():
3613                        rstring = f" (requires {req})"
3614                    print(
3615                        (
3616                            f"  {transition!r} ->"
3617                            f" {graph.identityOf(destination)}{rstring}"
3618                        ),
3619                        file=sys.stderr
3620                    )
3621
3622                if len(actions) > 0:
3623                    print(
3624                        f"There are {len(actions)} actions:",
3625                        file=sys.stderr
3626                    )
3627                    for oneAction in actions:
3628                        req = graph.getTransitionRequirement(
3629                            here,
3630                            oneAction
3631                        )
3632                        rstring = ''
3633                        if req != base.ReqNothing():
3634                            rstring = f" (requires {req})"
3635                        print(
3636                            f"  {oneAction!r}{rstring}",
3637                            file=sys.stderr
3638                        )
3639
3640        elif action == "steps":
3641            steps = len(self.getExploration())
3642            if self.prevSteps is not None:
3643                elapsed = steps - cast(int, self.prevSteps)
3644                print(
3645                    (
3646                        f"There are {steps} steps in the current"
3647                        f" exploration (which is {elapsed} more than"
3648                        f" there were at the previous check)."
3649                    ),
3650                    file=sys.stderr
3651                )
3652            else:
3653                print(
3654                    (
3655                        f"There are {steps} steps in the current"
3656                        f" exploration."
3657                    ),
3658                    file=sys.stderr
3659                )
3660            self.prevSteps = steps
3661
3662        elif action == "decisions":
3663            count = len(self.getExploration().getSituation().graph)
3664            if self.prevDecisions is not None:
3665                elapsed = count - self.prevDecisions
3666                print(
3667                    (
3668                        f"There are {count} decisions in the current"
3669                        f" graph (which is {elapsed} more than there"
3670                        f" were at the previous check)."
3671                    ),
3672                    file=sys.stderr
3673                )
3674            else:
3675                print(
3676                    (
3677                        f"There are {count} decisions in the current"
3678                        f" graph."
3679                    ),
3680                    file=sys.stderr
3681                )
3682            self.prevDecisions = count
3683        elif action == "active":
3684            active = self.exploration.getActiveDecisions()
3685            now = self.exploration.getSituation()
3686            print(
3687                "Active decisions:\n",
3688                now.graph.namesListing(active),
3689                file=sys.stderr
3690            )
3691        elif action == "primary":
3692            e = self.exploration
3693            primary = e.primaryDecision()
3694            if primary is None:
3695                pr = "None"
3696            else:
3697                pr = e.getSituation().graph.identityOf(primary)
3698            print(f"Primary decision: {pr}", file=sys.stderr)
3699        elif action == "saved":
3700            now = self.exploration.getSituation()
3701            slot = base.DEFAULT_SAVE_SLOT
3702            if arg != "":
3703                slot = arg
3704            saved = now.saves.get(slot)
3705            if saved is None:
3706                print(f"Slot {slot!r} has no saved data.", file=sys.stderr)
3707            else:
3708                savedGraph, savedState = saved
3709                savedPrimary = savedGraph.identityOf(
3710                    savedState['primaryDecision']
3711                )
3712                print(f"Saved at decision: {savedPrimary}", file=sys.stderr)
3713        elif action == "inventory":
3714            now = self.exploration.getSituation()
3715            commonCap = now.state['common']['capabilities']
3716            activeCap = now.state['contexts'][now.state['activeContext']][
3717                'capabilities'
3718            ]
3719            merged = base.mergeCapabilitySets(commonCap, activeCap)
3720            capCount = len(merged['capabilities'])
3721            tokCount = len(merged['tokens'])
3722            skillCount = len(merged['skills'])
3723            print(
3724                (
3725                    f"{capCount} capability/ies, {tokCount} token type(s),"
3726                    f" and {skillCount} skill(s)"
3727                ),
3728                file=sys.stderr
3729            )
3730            if capCount > 0:
3731                print("Capabilities (alphabetical order):", file=sys.stderr)
3732                for cap in sorted(merged['capabilities']):
3733                    print(f"  {cap!r}", file=sys.stderr)
3734            if tokCount > 0:
3735                print("Tokens (alphabetical order):", file=sys.stderr)
3736                for tok in sorted(merged['tokens']):
3737                    print(
3738                        f"  {tok!r}: {merged['tokens'][tok]}",
3739                        file=sys.stderr
3740                    )
3741            if skillCount > 0:
3742                print("Skill levels (alphabetical order):", file=sys.stderr)
3743                for skill in sorted(merged['skills']):
3744                    print(
3745                        f"  {skill!r}: {merged['skills'][skill]}",
3746                        file=sys.stderr
3747                    )
3748        elif action == "mechanisms":
3749            now = self.exploration.getSituation()
3750            grpah = now.graph
3751            mechs = now.state['mechanisms']
3752            inGraph = set(graph.mechanisms) - set(mechs)
3753            print(
3754                (
3755                    f"{len(mechs)} mechanism(s) in known states;"
3756                    f" {len(inGraph)} additional mechanism(s) in the"
3757                    f" default state"
3758                ),
3759                file=sys.stderr
3760            )
3761            if len(mechs) > 0:
3762                print("Mechanism(s) in known state(s):", file=sys.stderr)
3763                for mID in sorted(mechs):
3764                    mState = mechs[mID]
3765                    whereID, mName = graph.mechanisms[mID]
3766                    if whereID is None:
3767                        whereStr = " (global)"
3768                    else:
3769                        domain = graph.domainFor(whereID)
3770                        whereStr = f" at {graph.identityOf(whereID)}"
3771                    print(
3772                        f"  {mName}:{mState!r} - {mID}{whereStr}",
3773                        file=sys.stderr
3774                    )
3775            if len(inGraph) > 0:
3776                print("Mechanism(s) in the default state:", file=sys.stderr)
3777                for mID in sorted(inGraph):
3778                    whereID, mName = graph.mechanisms[mID]
3779                    if whereID is None:
3780                        whereStr = " (global)"
3781                    else:
3782                        domain = graph.domainFor(whereID)
3783                        whereStr = f" at {graph.identityOf(whereID)}"
3784                    print(f"  {mID} - {mName}){whereStr}", file=sys.stderr)
3785        elif action == "equivalences":
3786            now = self.exploration.getSituation()
3787            eqDict = now.graph.equivalences
3788            if len(eqDict) > 0:
3789                print(f"{len(eqDict)} equivalences:", file=sys.stderr)
3790                for hasEq in eqDict:
3791                    if isinstance(hasEq, tuple):
3792                        assert len(hasEq) == 2
3793                        assert isinstance(hasEq[0], base.MechanismID)
3794                        assert isinstance(hasEq[1], base.MechanismState)
3795                        mID, mState = hasEq
3796                        mDetails = now.graph.mechanismDetails(mID)
3797                        assert mDetails is not None
3798                        mWhere, mName = mDetails
3799                        if mWhere is None:
3800                            whereStr = " (global)"
3801                        else:
3802                            whereStr = f" at {graph.identityOf(mWhere)}"
3803                        eqStr = f"{mName}:{mState!r} - {mID}{whereStr}"
3804                    else:
3805                        assert isinstance(hasEq, base.Capability)
3806                        eqStr = hasEq
3807                    eqSet = eqDict[hasEq]
3808                    print(
3809                        f"  {eqStr} has {len(eqSet)} equivalence(s):",
3810                        file=sys.stderr
3811                    )
3812                    for eqReq in eqDict[hasEq]:
3813                        print(f"    {eqReq}", file=sys.stderr)
3814            else:
3815                print(
3816                    "There are no equivalences right now.",
3817                    file=sys.stderr
3818                )
3819        else:
3820            raise JournalParseError(
3821                f"Invalid debug command: {action!r}"
3822            )
3823
3824    def recordStart(
3825        self,
3826        where: Union[base.DecisionName, base.DecisionSpecifier],
3827        decisionType: base.DecisionType = 'imposed'
3828    ) -> None:
3829        """
3830        Records the start of the exploration. Use only once in each new
3831        domain, as the very first action in that domain (possibly after
3832        some zone declarations). The contextual domain is used if the
3833        given `base.DecisionSpecifier` doesn't include a domain.
3834
3835        To create new decision points that are disconnected from the rest
3836        of the graph that aren't the first in their domain, use the
3837        `relative` method followed by `recordWarp`.
3838
3839        The default 'imposed' decision type can be overridden for the
3840        action that this generates.
3841        """
3842        if self.inRelativeMode:
3843            raise JournalParseError(
3844                "Can't start the exploration in relative mode."
3845            )
3846
3847        whereSpec: Union[base.DecisionID, base.DecisionSpecifier]
3848        if isinstance(where, base.DecisionName):
3849            whereSpec = self.parseFormat.parseDecisionSpecifier(where)
3850            if isinstance(whereSpec, base.DecisionID):
3851                raise JournalParseError(
3852                    f"Can't use a number for a decision name. Got:"
3853                    f" {where!r}"
3854                )
3855        else:
3856            whereSpec = where
3857
3858        if whereSpec.domain is None:
3859            whereSpec = base.DecisionSpecifier(
3860                domain=self.context['domain'],
3861                zone=whereSpec.zone,
3862                name=whereSpec.name
3863            )
3864        self.context['decision'] = self.exploration.start(
3865            whereSpec,
3866            decisionType=decisionType
3867        )
3868
3869    def recordObserveAction(self, name: base.Transition) -> None:
3870        """
3871        Records the observation of an action at the current decision,
3872        which has the given name.
3873        """
3874        here = self.definiteDecisionTarget()
3875        self.exploration.getSituation().graph.addAction(here, name)
3876        self.context['transition'] = (here, name)
3877
3878    def recordObserve(
3879        self,
3880        name: base.Transition,
3881        destination: Optional[base.AnyDecisionSpecifier] = None,
3882        reciprocal: Optional[base.Transition] = None
3883    ) -> None:
3884        """
3885        Records the observation of a new option at the current decision.
3886
3887        If two or three arguments are given, the destination is still
3888        marked as unexplored, but is given a name (with two arguments)
3889        and the reciprocal transition is named (with three arguments).
3890
3891        When a name or decision specifier is used for the destination,
3892        the domain and/or level-0 zone of the current decision are
3893        filled in if the specifier is a name or doesn't have domain
3894        and/or zone info. The first alphabetical level-0 zone is used if
3895        the current decision is in more than one.
3896        """
3897        here = self.definiteDecisionTarget()
3898
3899        # Our observation matches `DiscreteExploration.observe` args
3900        obs: Union[
3901            Tuple[base.Transition],
3902            Tuple[base.Transition, base.AnyDecisionSpecifier],
3903            Tuple[
3904                base.Transition,
3905                base.AnyDecisionSpecifier,
3906                base.Transition
3907            ]
3908        ]
3909
3910        # If we have a destination, parse it as a decision specifier
3911        # (might be an ID)
3912        if isinstance(destination, str):
3913            destination = self.parseFormat.parseDecisionSpecifier(
3914                destination
3915            )
3916
3917        # If we started with a name or some other kind of decision
3918        # specifier, replace missing domain and/or zone info with info
3919        # from the current decision.
3920        if isinstance(destination, base.DecisionSpecifier):
3921            destination = base.spliceDecisionSpecifiers(
3922                destination,
3923                self.decisionTargetSpecifier()
3924            )
3925            # TODO: This is kinda janky because it only uses 1 zone,
3926            # whereas explore puts the new decision in all of them.
3927
3928        # Set up our observation argument
3929        if destination is not None:
3930            if reciprocal is not None:
3931                obs = (name, destination, reciprocal)
3932            else:
3933                obs = (name, destination)
3934        elif reciprocal is not None:
3935            # TODO: Allow this? (make the destination generic)
3936            raise JournalParseError(
3937                "You may not specify a reciprocal name without"
3938                " specifying a destination."
3939            )
3940        else:
3941            obs = (name,)
3942
3943        self.exploration.observe(here, *obs)
3944        self.context['transition'] = (here, name)
3945
3946    def recordObservationIncomplete(
3947        self,
3948        decision: base.AnyDecisionSpecifier
3949    ):
3950        """
3951        Marks a particular decision as being incompletely-observed.
3952        Normally whenever we leave a decision, we set its exploration
3953        status as 'explored' under the assumption that before moving on
3954        to another decision, we'll note down all of the options at this
3955        one first. Usually, to indicate further exploration
3956        possibilities in a room, you can include a transition, and you
3957        could even use `recordUnify` later to indicate that what seemed
3958        like a junction between two decisions really wasn't, and they
3959        should be merged. But in rare cases, it makes sense instead to
3960        indicate before you leave a decision that you expect to see more
3961        options there later, but you can't or won't observe them now.
3962        Once `recordObservationIncomplete` has been called, the default
3963        mechanism will never upgrade the decision to 'explored', and you
3964        will usually want to eventually call `recordStatus` to
3965        explicitly do that (which also removes it from the
3966        `dontFinalize` set that this method puts it in).
3967
3968        When called on a decision which already has exploration status
3969        'explored', this also sets the exploration status back to
3970        'exploring'.
3971        """
3972        e = self.exploration
3973        dID = e.getSituation().graph.resolveDecision(decision)
3974        if e.getExplorationStatus(dID) == 'explored':
3975            e.setExplorationStatus(dID, 'exploring')
3976        self.dontFinalize.add(dID)
3977
3978    def recordStatus(
3979        self,
3980        decision: base.AnyDecisionSpecifier,
3981        status: base.ExplorationStatus = 'explored'
3982    ):
3983        """
3984        Explicitly records that a particular decision has the specified
3985        exploration status (default 'explored' meaning we think we've
3986        seen everything there). This helps analysts look for unexpected
3987        connections.
3988
3989        Note that normally, exploration statuses will be updated
3990        automatically whenever a decision is first observed (status
3991        'noticed'), first visited (status 'exploring') and first left
3992        behind (status 'explored'). However, using
3993        `recordObservationIncomplete` can prevent the automatic
3994        'explored' update.
3995
3996        This method also removes a decision's `dontFinalize` entry,
3997        although it's probably no longer relevant in any case.
3998        TODO: Still this?
3999
4000        A basic example:
4001
4002        >>> obs = JournalObserver()
4003        >>> e = obs.getExploration()
4004        >>> obs.recordStart('A')
4005        >>> e.getExplorationStatus('A', 0)
4006        'unknown'
4007        >>> e.getExplorationStatus('A', 1)
4008        'exploring'
4009        >>> obs.recordStatus('A')
4010        >>> e.getExplorationStatus('A', 1)
4011        'explored'
4012        >>> obs.recordStatus('A', 'hypothesized')
4013        >>> e.getExplorationStatus('A', 1)
4014        'hypothesized'
4015
4016        An example of usage in journal format:
4017
4018        >>> obs = JournalObserver()
4019        >>> obs.observe('''
4020        ... # step 0
4021        ... S A  # step 1
4022        ... x right B left  # step 2
4023        ...   ...
4024        ... x right C left  # step 3
4025        ... t left  # back to B; step 4
4026        ...   o up
4027        ...   .  # now we think we've found all options
4028        ... x up D down  # step 5
4029        ... t down  # back to B again; step 6
4030        ... x down E up  # surprise extra option; step 7
4031        ... w  # step 8
4032        ...   . hypothesized  # explicit value
4033        ... t up  # auto-updates to 'explored'; step 9
4034        ... ''')
4035        >>> e = obs.getExploration()
4036        >>> len(e)
4037        10
4038        >>> e.getExplorationStatus('A', 1)
4039        'exploring'
4040        >>> e.getExplorationStatus('A', 2)
4041        'explored'
4042        >>> e.getExplorationStatus('B', 1)
4043        Traceback (most recent call last):
4044        ...
4045        exploration.core.MissingDecisionError...
4046        >>> e.getExplorationStatus(1, 1)  # the unknown node is created
4047        'unknown'
4048        >>> e.getExplorationStatus('B', 2)
4049        'exploring'
4050        >>> e.getExplorationStatus('B', 3)  # not 'explored' yet
4051        'exploring'
4052        >>> e.getExplorationStatus('B', 4)  # now explored
4053        'explored'
4054        >>> e.getExplorationStatus('B', 6)  # still explored
4055        'explored'
4056        >>> e.getExplorationStatus('E', 7)  # initial
4057        'exploring'
4058        >>> e.getExplorationStatus('E', 8)  # explicit
4059        'hypothesized'
4060        >>> e.getExplorationStatus('E', 9)  # auto-update on leave
4061        'explored'
4062        >>> g2 = e.getSituation(2).graph
4063        >>> g4 = e.getSituation(4).graph
4064        >>> g7 = e.getSituation(7).graph
4065        >>> g2.destinationsFrom('B')
4066        {'left': 0, 'right': 2}
4067        >>> g4.destinationsFrom('B')
4068        {'left': 0, 'right': 2, 'up': 3}
4069        >>> g7.destinationsFrom('B')
4070        {'left': 0, 'right': 2, 'up': 3, 'down': 4}
4071        """
4072        e = self.exploration
4073        dID = e.getSituation().graph.resolveDecision(decision)
4074        if dID in self.dontFinalize:
4075            self.dontFinalize.remove(dID)
4076        e.setExplorationStatus(decision, status)
4077
4078    def autoFinalizeExplorationStatuses(self):
4079        """
4080        Looks at the set of nodes that were active in the previous
4081        exploration step but which are no longer active in this one, and
4082        sets their exploration statuses to 'explored' to indicate that
4083        we believe we've already at least observed all of their outgoing
4084        transitions.
4085
4086        Skips finalization for any decisions in our `dontFinalize` set
4087        (see `recordObservationIncomplete`).
4088        """
4089        oldActive = self.exploration.getActiveDecisions(-2)
4090        newAcive = self.exploration.getActiveDecisions()
4091        for leftBehind in (oldActive - newAcive) - self.dontFinalize:
4092            self.exploration.setExplorationStatus(
4093                leftBehind,
4094                'explored'
4095            )
4096
4097    def recordExplore(
4098        self,
4099        transition: base.AnyTransition,
4100        destination: Optional[base.AnyDecisionSpecifier] = None,
4101        reciprocal: Optional[base.Transition] = None,
4102        decisionType: base.DecisionType = 'active'
4103    ) -> None:
4104        """
4105        Records the exploration of a transition which leads to a
4106        specific destination (possibly with outcomes specified for
4107        challenges that are part of that transition's consequences). The
4108        name of the reciprocal transition may also be specified, as can
4109        a non-default decision type (see `base.DecisionType`). Creates
4110        the transition if it needs to.
4111
4112        Note that if the destination specifier has no zone or domain
4113        information, even if a decision with that name already exists, if
4114        the current decision is in a level-0 zone and the existing
4115        decision is not in the same zone, a new decision with that name
4116        in the current level-0 zone will be created (otherwise, it would
4117        be an error to use 'explore' to connect to an already-visited
4118        decision).
4119
4120        If no destination name is specified, the destination node must
4121        already exist and the name of the destination must not begin
4122        with '_u.' otherwise a `JournalParseError` will be generated.
4123
4124        Sets the current transition to the transition taken.
4125
4126        Calls `autoFinalizeExplorationStatuses` to upgrade exploration
4127        statuses for no-longer-active nodes to 'explored'.
4128
4129        In relative mode, this makes all the same changes to the graph,
4130        without adding a new exploration step, applying transition
4131        effects, or changing exploration statuses.
4132        """
4133        here = self.definiteDecisionTarget()
4134
4135        transitionName, outcomes = base.nameAndOutcomes(transition)
4136
4137        # Create transition if it doesn't already exist
4138        now = self.exploration.getSituation()
4139        graph = now.graph
4140        leadsTo = graph.getDestination(here, transitionName)
4141
4142        if isinstance(destination, str):
4143            destination = self.parseFormat.parseDecisionSpecifier(
4144                destination
4145            )
4146
4147        newDomain: Optional[base.Domain]
4148        newZone: Union[
4149            base.Zone,
4150            type[base.DefaultZone],
4151            None
4152        ] = base.DefaultZone
4153        newName: Optional[base.DecisionName]
4154
4155        # if a destination is specified, we need to check that it's not
4156        # an already-existing decision
4157        connectBack: bool = False  # are we connecting to a known decision?
4158        if destination is not None:
4159            # If it's not an ID, splice in current node info:
4160            if isinstance(destination, base.DecisionName):
4161                destination = base.DecisionSpecifier(None, None, destination)
4162            if isinstance(destination, base.DecisionSpecifier):
4163                destination = base.spliceDecisionSpecifiers(
4164                    destination,
4165                    self.decisionTargetSpecifier()
4166                )
4167            exists = graph.getDecision(destination)
4168            # if the specified decision doesn't exist; great. We'll
4169            # create it below
4170            if exists is not None:
4171                # If it does exist, we may have a problem. 'return' must
4172                # be used instead of 'explore' to connect to an existing
4173                # visited decision. But let's see if we really have a
4174                # conflict?
4175                otherZones = set(
4176                    z
4177                    for z in graph.zoneParents(exists)
4178                    if graph.zoneHierarchyLevel(z) == 0
4179                )
4180                currentZones = set(
4181                    z
4182                    for z in graph.zoneParents(here)
4183                    if graph.zoneHierarchyLevel(z) == 0
4184                )
4185                if (
4186                    len(otherZones & currentZones) != 0
4187                 or (
4188                        len(otherZones) == 0
4189                    and len(currentZones) == 0
4190                    )
4191                ):
4192                    if self.exploration.hasBeenVisited(exists):
4193                        # A decision by this name exists and shares at
4194                        # least one level-0 zone with the current
4195                        # decision. That means that 'return' should have
4196                        # been used.
4197                        raise JournalParseError(
4198                            f"Destiation {destination} is invalid"
4199                            f" because that decision has already been"
4200                            f" visited in the current zone. Use"
4201                            f" 'return' to record a new connection to"
4202                            f" an already-visisted decision."
4203                        )
4204                    else:
4205                        connectBack = True
4206                else:
4207                    connectBack = True
4208                # Otherwise, we can continue; the DefaultZone setting
4209                # already in place will prevail below
4210
4211        # Figure out domain & zone info for new destination
4212        if isinstance(destination, base.DecisionSpecifier):
4213            # Use current decision's domain by default
4214            if destination.domain is not None:
4215                newDomain = destination.domain
4216            else:
4217                newDomain = graph.domainFor(here)
4218
4219            # Use specified zone if there is one, else leave it as
4220            # DefaultZone to inherit zone(s) from the current decision.
4221            if destination.zone is not None:
4222                newZone = destination.zone
4223
4224            newName = destination.name
4225            # TODO: Some way to specify non-zone placement in explore?
4226
4227        elif isinstance(destination, base.DecisionID):
4228            if connectBack:
4229                newDomain = graph.domainFor(here)
4230                newZone = None
4231                newName = None
4232            else:
4233                raise JournalParseError(
4234                    f"You cannot use a decision ID when specifying a"
4235                    f" new name for an exploration destination (got:"
4236                    f" {repr(destination)})"
4237                )
4238
4239        elif isinstance(destination, base.DecisionName):
4240            newDomain = None
4241            newZone = base.DefaultZone
4242            newName = destination
4243
4244        else:  # must be None
4245            assert destination is None
4246            newDomain = None
4247            newZone = base.DefaultZone
4248            newName = None
4249
4250        if leadsTo is None:
4251            if newName is None and not connectBack:
4252                raise JournalParseError(
4253                    f"Transition {transition!r} at decision"
4254                    f" {graph.identityOf(here)} does not already exist,"
4255                    f" so a destination name must be provided."
4256                )
4257            else:
4258                graph.addUnexploredEdge(
4259                    here,
4260                    transitionName,
4261                    toDomain=newDomain  # None is the default anyways
4262                )
4263                # Zone info only added in next step
4264        elif newName is None:
4265            # TODO: Generalize this... ?
4266            currentName = graph.nameFor(leadsTo)
4267            if currentName.startswith('_u.'):
4268                raise JournalParseError(
4269                    f"Destination {graph.identityOf(leadsTo)} from"
4270                    f" decision {graph.identityOf(here)} via transition"
4271                    f" {transition!r} must be named when explored,"
4272                    f" because its current name is a placeholder."
4273                )
4274            else:
4275                newName = currentName
4276
4277        # TODO: Check for incompatible domain/zone in destination
4278        # specifier?
4279
4280        if self.inRelativeMode:
4281            if connectBack:  # connect to existing unconfirmed decision
4282                assert exists is not None
4283                graph.replaceUnconfirmed(
4284                    here,
4285                    transitionName,
4286                    exists,
4287                    reciprocal
4288                )  # we assume zones are already in place here
4289                self.exploration.setExplorationStatus(
4290                    exists,
4291                    'noticed',
4292                    upgradeOnly=True
4293                )
4294            else:  # connect to a new decision
4295                graph.replaceUnconfirmed(
4296                    here,
4297                    transitionName,
4298                    newName,
4299                    reciprocal,
4300                    placeInZone=newZone,
4301                    forceNew=True
4302                )
4303                destID = graph.destination(here, transitionName)
4304                self.exploration.setExplorationStatus(
4305                    destID,
4306                    'noticed',
4307                    upgradeOnly=True
4308                )
4309            self.context['decision'] = graph.destination(
4310                here,
4311                transitionName
4312            )
4313            self.context['transition'] = (here, transitionName)
4314        else:
4315            if connectBack:  # to a known but unvisited decision
4316                destID = self.exploration.explore(
4317                    (transitionName, outcomes),
4318                    exists,
4319                    reciprocal,
4320                    zone=newZone,
4321                    decisionType=decisionType
4322                )
4323            else:  # to an entirely new decision
4324                destID = self.exploration.explore(
4325                    (transitionName, outcomes),
4326                    newName,
4327                    reciprocal,
4328                    zone=newZone,
4329                    decisionType=decisionType
4330                )
4331            self.context['decision'] = destID
4332            self.context['transition'] = (here, transitionName)
4333            self.autoFinalizeExplorationStatuses()
4334
4335    def recordRetrace(
4336        self,
4337        transition: base.AnyTransition,
4338        decisionType: base.DecisionType = 'active',
4339        isAction: Optional[bool] = None
4340    ) -> None:
4341        """
4342        Records retracing a transition which leads to a known
4343        destination. A non-default decision type can be specified. If
4344        `isAction` is True or False, the transition must be (or must not
4345        be) an action (i.e., a transition whose destination is the same
4346        as its source). If `isAction` is left as `None` (the default)
4347        then either normal or action transitions can be retraced.
4348
4349        Sets the current transition to the transition taken.
4350
4351        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4352
4353        In relative mode, simply sets the current transition target to
4354        the transition taken and sets the current decision target to its
4355        destination (it does not apply transition effects).
4356        """
4357        here = self.definiteDecisionTarget()
4358
4359        transitionName, outcomes = base.nameAndOutcomes(transition)
4360
4361        graph = self.exploration.getSituation().graph
4362        destination = graph.getDestination(here, transitionName)
4363        if destination is None:
4364            valid = graph.destinationsListing(graph.destinationsFrom(here))
4365            raise JournalParseError(
4366                f"Cannot retrace transition {transitionName!r} from"
4367                f" decision {graph.identityOf(here)}: that transition"
4368                f" does not exist. Destinations available are:"
4369                f"\n{valid}"
4370            )
4371        if isAction is True and destination != here:
4372            raise JournalParseError(
4373                f"Cannot retrace transition {transitionName!r} from"
4374                f" decision {graph.identityOf(here)}: that transition"
4375                f" leads to {graph.identityOf(destination)} but you"
4376                f" specified that an existing action should be retraced,"
4377                f" not a normal transition. Use `recordAction` instead"
4378                f" to record a new action (including converting an"
4379                f" unconfirmed transition into an action). Leave"
4380                f" `isAction` unspeicfied or set it to `False` to"
4381                f" retrace a normal transition."
4382            )
4383        elif isAction is False and destination == here:
4384            raise JournalParseError(
4385                f"Cannot retrace transition {transitionName!r} from"
4386                f" decision {graph.identityOf(here)}: that transition"
4387                f" leads back to {graph.identityOf(destination)} but you"
4388                f" specified that an outgoing transition should be"
4389                f" retraced, not an action. Use `recordAction` instead"
4390                f" to record a new action (which must not have the same"
4391                f" name as any outgoing transition). Leave `isAction`"
4392                f" unspeicfied or set it to `True` to retrace an action."
4393            )
4394
4395        if not self.inRelativeMode:
4396            destID = self.exploration.retrace(
4397                (transitionName, outcomes),
4398                decisionType=decisionType
4399            )
4400            self.autoFinalizeExplorationStatuses()
4401        self.context['decision'] = destID
4402        self.context['transition'] = (here, transitionName)
4403
4404    def recordAction(
4405        self,
4406        action: base.AnyTransition,
4407        decisionType: base.DecisionType = 'active'
4408    ) -> None:
4409        """
4410        Records a new action taken at the current decision. A
4411        non-standard decision type may be specified. If a transition of
4412        that name already existed, it will be converted into an action
4413        assuming that its destination is unexplored and has no
4414        connections yet, and that its reciprocal also has no special
4415        properties yet. If those assumptions do not hold, a
4416        `JournalParseError` will be raised under the assumption that the
4417        name collision was an accident, not intentional, since the
4418        destination and reciprocal are deleted in the process of
4419        converting a normal transition into an action.
4420
4421        This cannot be used to re-triggger an existing action, use
4422        'retrace' for that.
4423
4424        In relative mode, the action is created (or the transition is
4425        converted into an action) but effects are not applied.
4426
4427        Although this does not usually change which decisions are
4428        active, it still calls `autoFinalizeExplorationStatuses` unless
4429        in relative mode.
4430
4431        Example:
4432
4433        >>> o = JournalObserver()
4434        >>> e = o.getExploration()
4435        >>> o.recordStart('start')
4436        >>> o.recordObserve('transition')
4437        >>> e.effectiveCapabilities()['capabilities']
4438        set()
4439        >>> o.recordObserveAction('action')
4440        >>> o.recordTransitionConsequence([base.effect(gain="capability")])
4441        >>> o.recordRetrace('action', isAction=True)
4442        >>> e.effectiveCapabilities()['capabilities']
4443        {'capability'}
4444        >>> o.recordAction('another') # add effects after...
4445        >>> effect = base.effect(lose="capability")
4446        >>> # This applies the effect and then adds it to the
4447        >>> # transition, since we already took the transition
4448        >>> o.recordAdditionalTransitionConsequence([effect])
4449        >>> e.effectiveCapabilities()['capabilities']
4450        set()
4451        >>> len(e)
4452        4
4453        >>> e.getActiveDecisions(0)
4454        set()
4455        >>> e.getActiveDecisions(1)
4456        {0}
4457        >>> e.getActiveDecisions(2)
4458        {0}
4459        >>> e.getActiveDecisions(3)
4460        {0}
4461        >>> e.getSituation(0).action
4462        ('start', 0, 0, 'main', None, None, None)
4463        >>> e.getSituation(1).action
4464        ('take', 'active', 0, ('action', []))
4465        >>> e.getSituation(2).action
4466        ('take', 'active', 0, ('another', []))
4467        """
4468        here = self.definiteDecisionTarget()
4469
4470        actionName, outcomes = base.nameAndOutcomes(action)
4471
4472        # Check if the transition already exists
4473        now = self.exploration.getSituation()
4474        graph = now.graph
4475        hereIdent = graph.identityOf(here)
4476        destinations = graph.destinationsFrom(here)
4477
4478        # A transition going somewhere else
4479        if actionName in destinations:
4480            if destinations[actionName] == here:
4481                raise JournalParseError(
4482                    f"Action {actionName!r} already exists as an action"
4483                    f" at decision {hereIdent!r}. Use 'retrace' to"
4484                    " re-activate an existing action."
4485                )
4486            else:
4487                destination = destinations[actionName]
4488                reciprocal = graph.getReciprocal(here, actionName)
4489                # To replace a transition with an action, the transition
4490                # may only have outgoing properties. Otherwise we assume
4491                # it's an error to name the action after a transition
4492                # which was intended to be a real transition.
4493                if (
4494                    graph.isConfirmed(destination)
4495                 or self.exploration.hasBeenVisited(destination)
4496                 or cast(int, graph.degree(destination)) > 2
4497                    # TODO: Fix MultiDigraph type stubs...
4498                ):
4499                    raise JournalParseError(
4500                        f"Action {actionName!r} has the same name as"
4501                        f" outgoing transition {actionName!r} at"
4502                        f" decision {hereIdent!r}. We cannot turn that"
4503                        f" transition into an action since its"
4504                        f" destination is already explored or has been"
4505                        f" connected to."
4506                    )
4507                if (
4508                    reciprocal is not None
4509                and graph.getTransitionProperties(
4510                        destination,
4511                        reciprocal
4512                    ) != {
4513                        'requirement': base.ReqNothing(),
4514                        'effects': [],
4515                        'tags': {},
4516                        'annotations': []
4517                    }
4518                ):
4519                    raise JournalParseError(
4520                        f"Action {actionName!r} has the same name as"
4521                        f" outgoing transition {actionName!r} at"
4522                        f" decision {hereIdent!r}. We cannot turn that"
4523                        f" transition into an action since its"
4524                        f" reciprocal has custom properties."
4525                    )
4526
4527                if (
4528                    graph.decisionAnnotations(destination) != []
4529                 or graph.decisionTags(destination) != {'unknown': 1}
4530                ):
4531                    raise JournalParseError(
4532                        f"Action {actionName!r} has the same name as"
4533                        f" outgoing transition {actionName!r} at"
4534                        f" decision {hereIdent!r}. We cannot turn that"
4535                        f" transition into an action since its"
4536                        f" destination has tags and/or annotations."
4537                    )
4538
4539                # If we get here, re-target the transition, and then
4540                # destroy the old destination along with the old
4541                # reciprocal edge.
4542                graph.retargetTransition(
4543                    here,
4544                    actionName,
4545                    here,
4546                    swapReciprocal=False
4547                )
4548                graph.removeDecision(destination)
4549
4550        # This will either take the existing action OR create it if
4551        # necessary
4552        if self.inRelativeMode:
4553            if actionName not in destinations:
4554                graph.addAction(here, actionName)
4555        else:
4556            destID = self.exploration.takeAction(
4557                (actionName, outcomes),
4558                fromDecision=here,
4559                decisionType=decisionType
4560            )
4561            self.autoFinalizeExplorationStatuses()
4562            self.context['decision'] = destID
4563        self.context['transition'] = (here, actionName)
4564
4565    def recordReturn(
4566        self,
4567        transition: base.AnyTransition,
4568        destination: Optional[base.AnyDecisionSpecifier] = None,
4569        reciprocal: Optional[base.Transition] = None,
4570        decisionType: base.DecisionType = 'active'
4571    ) -> None:
4572        """
4573        Records an exploration which leads back to a
4574        previously-encountered decision. If a reciprocal is specified,
4575        we connect to that transition as our reciprocal (it must have
4576        led to an unknown area or not have existed) or if not, we make a
4577        new connection with an automatic reciprocal name.
4578        A non-standard decision type may be specified.
4579
4580        If no destination is specified, then the destination of the
4581        transition must already exist.
4582
4583        If the specified transition does not exist, it will be created.
4584
4585        Sets the current transition to the transition taken.
4586
4587        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4588
4589        In relative mode, does the same stuff but doesn't apply any
4590        transition effects.
4591        """
4592        here = self.definiteDecisionTarget()
4593        now = self.exploration.getSituation()
4594        graph = now.graph
4595
4596        transitionName, outcomes = base.nameAndOutcomes(transition)
4597
4598        if destination is None:
4599            destination = graph.getDestination(here, transitionName)
4600            if destination is None:
4601                raise JournalParseError(
4602                    f"Cannot 'return' across transition"
4603                    f" {transitionName!r} from decision"
4604                    f" {graph.identityOf(here)} without specifying a"
4605                    f" destination, because that transition does not"
4606                    f" already have a destination."
4607                )
4608
4609        if isinstance(destination, str):
4610            destination = self.parseFormat.parseDecisionSpecifier(
4611                destination
4612            )
4613
4614        # If we started with a name or some other kind of decision
4615        # specifier, replace missing domain and/or zone info with info
4616        # from the current decision.
4617        if isinstance(destination, base.DecisionSpecifier):
4618            destination = base.spliceDecisionSpecifiers(
4619                destination,
4620                self.decisionTargetSpecifier()
4621            )
4622
4623        # Add an unexplored edge just before doing the return if the
4624        # named transition didn't already exist.
4625        if graph.getDestination(here, transitionName) is None:
4626            graph.addUnexploredEdge(here, transitionName)
4627
4628        # Works differently in relative mode
4629        if self.inRelativeMode:
4630            graph.replaceUnconfirmed(
4631                here,
4632                transitionName,
4633                destination,
4634                reciprocal
4635            )
4636            self.context['decision'] = graph.resolveDecision(destination)
4637            self.context['transition'] = (here, transitionName)
4638        else:
4639            destID = self.exploration.returnTo(
4640                (transitionName, outcomes),
4641                destination,
4642                reciprocal,
4643                decisionType=decisionType
4644            )
4645            self.autoFinalizeExplorationStatuses()
4646            self.context['decision'] = destID
4647            self.context['transition'] = (here, transitionName)
4648
4649    def recordWarp(
4650        self,
4651        destination: base.AnyDecisionSpecifier,
4652        decisionType: base.DecisionType = 'active'
4653    ) -> None:
4654        """
4655        Records a warp to a specific destination without creating a
4656        transition. If the destination did not exist, it will be
4657        created (but only if a `base.DecisionName` or
4658        `base.DecisionSpecifier` was supplied; a destination cannot be
4659        created based on a non-existent `base.DecisionID`).
4660        A non-standard decision type may be specified.
4661
4662        If the destination already exists its zones won't be changed.
4663        However, if the destination gets created, it will be in the same
4664        domain and added to the same zones as the previous position, or
4665        to whichever zone was specified as the zone component of a
4666        `base.DecisionSpecifier`, if any.
4667
4668        Sets the current transition to `None`.
4669
4670        In relative mode, simply updates the current target decision and
4671        sets the current target transition to `None`. It will still
4672        create the destination if necessary, possibly putting it in a
4673        zone. In relative mode, the destination's exploration status is
4674        set to "noticed" (and no exploration step is created), while in
4675        normal mode, the exploration status is set to 'unknown' in the
4676        original current step, and then a new step is added which will
4677        set the status to 'exploring'.
4678
4679        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4680        """
4681        now = self.exploration.getSituation()
4682        graph = now.graph
4683
4684        if isinstance(destination, str):
4685            destination = self.parseFormat.parseDecisionSpecifier(
4686                destination
4687            )
4688
4689        destID = graph.getDecision(destination)
4690
4691        newZone: Union[
4692            base.Zone,
4693            type[base.DefaultZone],
4694            None
4695        ] = base.DefaultZone
4696        here = self.currentDecisionTarget()
4697        newDomain: Optional[base.Domain] = None
4698        if here is not None:
4699            newDomain = graph.domainFor(here)
4700        if self.inRelativeMode:  # create the decision if it didn't exist
4701            if destID not in graph:  # including if it's None
4702                if isinstance(destination, base.DecisionID):
4703                    raise JournalParseError(
4704                        f"Cannot go to decision {destination} because that"
4705                        f" decision ID does not exist, and we cannot create"
4706                        f" a new decision based only on a decision ID. Use"
4707                        f" a DecisionSpecifier or DecisionName to go to a"
4708                        f" new decision that needs to be created."
4709                    )
4710                elif isinstance(destination, base.DecisionName):
4711                    newName = destination
4712                    newZone = base.DefaultZone
4713                elif isinstance(destination, base.DecisionSpecifier):
4714                    specDomain, newZone, newName = destination
4715                    if specDomain is not None:
4716                        newDomain = specDomain
4717                else:
4718                    raise JournalParseError(
4719                        f"Invalid decision specifier: {repr(destination)}."
4720                        f" The destination must be a decision ID, a"
4721                        f" decision name, or a decision specifier."
4722                    )
4723                destID = graph.addDecision(newName, domain=newDomain)
4724                if newZone is base.DefaultZone:
4725                    ctxDecision = self.context['decision']
4726                    if ctxDecision is not None:
4727                        for zp in graph.zoneParents(ctxDecision):
4728                            graph.addDecisionToZone(destID, zp)
4729                elif newZone is not None:
4730                    graph.addDecisionToZone(destID, newZone)
4731                    # TODO: If this zone is new create it & add it to
4732                    # parent zones of old level-0 zone(s)?
4733
4734                base.setExplorationStatus(
4735                    now,
4736                    destID,
4737                    'noticed',
4738                    upgradeOnly=True
4739                )
4740                # TODO: Some way to specify 'hypothesized' here instead?
4741
4742        else:
4743            # in normal mode, 'DiscreteExploration.warp' takes care of
4744            # creating the decision if needed
4745            whichFocus = None
4746            if self.context['focus'] is not None:
4747                whichFocus = (
4748                    self.context['context'],
4749                    self.context['domain'],
4750                    self.context['focus']
4751                )
4752            if destination is None:
4753                destination = destID
4754
4755            if isinstance(destination, base.DecisionSpecifier):
4756                newZone = destination.zone
4757                if destination.domain is not None:
4758                    newDomain = destination.domain
4759            else:
4760                newZone = base.DefaultZone
4761
4762            destID = self.exploration.warp(
4763                destination,
4764                domain=newDomain,
4765                zone=newZone,
4766                whichFocus=whichFocus,
4767                inCommon=self.context['context'] == 'common',
4768                decisionType=decisionType
4769            )
4770            self.autoFinalizeExplorationStatuses()
4771
4772        self.context['decision'] = destID
4773        self.context['transition'] = None
4774
4775    def recordWait(
4776        self,
4777        decisionType: base.DecisionType = 'active'
4778    ) -> None:
4779        """
4780        Records a wait step. Does not modify the current transition.
4781        A non-standard decision type may be specified.
4782
4783        Raises a `JournalParseError` in relative mode, since it wouldn't
4784        have any effect.
4785        """
4786        if self.inRelativeMode:
4787            raise JournalParseError("Can't wait in relative mode.")
4788        else:
4789            self.exploration.wait(decisionType=decisionType)
4790
4791    def recordObserveEnding(self, name: base.DecisionName) -> None:
4792        """
4793        Records the observation of an action which warps to an ending,
4794        although unlike `recordEnd` we don't use that action yet. This
4795        does NOT update the current decision, although it sets the
4796        current transition to the action it creates.
4797
4798        The action created has the same name as the ending it warps to.
4799
4800        Note that normally, we just warp to endings, so there's no need
4801        to use `recordObserveEnding`. But if there's a player-controlled
4802        option to end the game at a particular node that is noticed
4803        before it's actually taken, this is the right thing to do.
4804
4805        We set up player-initiated ending transitions as actions with a
4806        goto rather than usual transitions because endings exist in a
4807        separate domain, and are active simultaneously with normal
4808        decisions.
4809        """
4810        graph = self.exploration.getSituation().graph
4811        here = self.definiteDecisionTarget()
4812        # Add the ending decision or grab the ID of the existing ending
4813        eID = graph.endingID(name)
4814        # Create action & add goto consequence
4815        graph.addAction(here, name)
4816        graph.setConsequence(here, name, [base.effect(goto=eID)])
4817        # Set the exploration status
4818        self.exploration.setExplorationStatus(
4819            eID,
4820            'noticed',
4821            upgradeOnly=True
4822        )
4823        self.context['transition'] = (here, name)
4824        # TODO: Prevent things like adding unexplored nodes to the
4825        # an ending...
4826
4827    def recordEnd(
4828        self,
4829        name: base.DecisionName,
4830        voluntary: bool = False,
4831        decisionType: Optional[base.DecisionType] = None
4832    ) -> None:
4833        """
4834        Records an ending. If `voluntary` is `False` (the default) then
4835        this becomes a warp that activates the specified ending (which
4836        is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave
4837        the current decision).
4838
4839        If `voluntary` is `True` then we also record an action with a
4840        'goto' effect that activates the specified ending, and record an
4841        exploration step that takes that action, instead of just a warp
4842        (`recordObserveEnding` would set up such an action without
4843        taking it).
4844
4845        The specified ending decision is created if it didn't already
4846        exist. If `voluntary` is True and an action that warps to the
4847        specified ending already exists with the correct name, we will
4848        simply take that action.
4849
4850        If it created an action, it sets the current transition to the
4851        action that warps to the ending. Endings are not added to zones;
4852        otherwise it sets the current transition to None.
4853
4854        In relative mode, an ending is still added, possibly with an
4855        action that warps to it, and the current decision is set to that
4856        ending node, but the transition doesn't actually get taken.
4857
4858        If not in relative mode, sets the exploration status of the
4859        current decision to `explored` if it wasn't in the
4860        `dontFinalize` set, even though we do not deactivate that
4861        transition.
4862
4863        When `voluntary` is not set, the decision type for the warp will
4864        be 'imposed', otherwise it will be 'active'. However, if an
4865        explicit `decisionType` is specified, that will override these
4866        defaults.
4867        """
4868        graph = self.exploration.getSituation().graph
4869        here = self.definiteDecisionTarget()
4870
4871        # Add our warping action if we need to
4872        if voluntary:
4873            # If voluntary, check for an existing warp action and set
4874            # one up if we don't have one.
4875            aDest = graph.getDestination(here, name)
4876            eID = graph.getDecision(
4877                base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name)
4878            )
4879            if aDest is None:
4880                # Okay we can just create the action
4881                self.recordObserveEnding(name)
4882                # else check if the existing transition is an action
4883                # that warps to the correct ending already
4884            elif (
4885                aDest != here
4886             or eID is None
4887             or not any(
4888                    c == base.effect(goto=eID)
4889                    for c in graph.getConsequence(here, name)
4890                )
4891            ):
4892                raise JournalParseError(
4893                    f"Attempting to add voluntary ending {name!r} at"
4894                    f" decision {graph.identityOf(here)} but that"
4895                    f" decision already has an action with that name"
4896                    f" and it's not set up to warp to that ending"
4897                    f" already."
4898                )
4899
4900        # Grab ending ID (creates the decision if necessary)
4901        eID = graph.endingID(name)
4902
4903        # Update our context variables
4904        self.context['decision'] = eID
4905        if voluntary:
4906            self.context['transition'] = (here, name)
4907        else:
4908            self.context['transition'] = None
4909
4910        # Update exploration status in relative mode, or possibly take
4911        # action in normal mode
4912        if self.inRelativeMode:
4913            self.exploration.setExplorationStatus(
4914                eID,
4915                "noticed",
4916                upgradeOnly=True
4917            )
4918        else:
4919            # Either take the action we added above, or just warp
4920            if decisionType is None:
4921                decisionType = 'active' if voluntary else 'imposed'
4922
4923            if voluntary:
4924                # Taking the action warps us to the ending
4925                self.exploration.takeAction(
4926                    name,
4927                    decisionType=decisionType
4928                )
4929            else:
4930                # We'll use a warp to get there
4931                self.exploration.warp(
4932                    base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name),
4933                    zone=None,
4934                    decisionType=decisionType
4935                )
4936                if (
4937                    here not in self.dontFinalize
4938                and (
4939                        self.exploration.getExplorationStatus(here)
4940                     == "exploring"
4941                    )
4942                ):
4943                    self.exploration.setExplorationStatus(here, "explored")
4944        # TODO: Prevent things like adding unexplored nodes to the
4945        # ending...
4946
4947    def recordMechanism(
4948        self,
4949        where: Optional[base.AnyDecisionSpecifier],
4950        name: base.MechanismName,
4951        startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE
4952    ) -> None:
4953        """
4954        Records the existence of a mechanism at the specified decision
4955        with the specified starting state (or the default starting
4956        state). Set `where` to `None` to set up a global mechanism that's
4957        not tied to any particular decision.
4958        """
4959        graph = self.exploration.getSituation().graph
4960        # TODO: a way to set up global mechanisms
4961        newID = graph.addMechanism(name, where)
4962        if startingState != base.DEFAULT_MECHANISM_STATE:
4963            self.exploration.setMechanismStateNow(newID, startingState)
4964
4965    def recordRequirement(self, req: Union[base.Requirement, str]) -> None:
4966        """
4967        Records a requirement observed on the most recently
4968        defined/taken transition. If a string is given,
4969        `ParseFormat.parseRequirement` will be used to parse it.
4970        """
4971        if isinstance(req, str):
4972            req = self.parseFormat.parseRequirement(req)
4973        target = self.currentTransitionTarget()
4974        if target is None:
4975            raise JournalParseError(
4976                "Can't set a requirement because there is no current"
4977                " transition."
4978            )
4979        graph = self.exploration.getSituation().graph
4980        graph.setTransitionRequirement(
4981            *target,
4982            req
4983        )
4984
4985    def recordReciprocalRequirement(
4986        self,
4987        req: Union[base.Requirement, str]
4988    ) -> None:
4989        """
4990        Records a requirement observed on the reciprocal of the most
4991        recently defined/taken transition. If a string is given,
4992        `ParseFormat.parseRequirement` will be used to parse it.
4993        """
4994        if isinstance(req, str):
4995            req = self.parseFormat.parseRequirement(req)
4996        target = self.currentReciprocalTarget()
4997        if target is None:
4998            raise JournalParseError(
4999                "Can't set a reciprocal requirement because there is no"
5000                " current transition or it doesn't have a reciprocal."
5001            )
5002        graph = self.exploration.getSituation().graph
5003        graph.setTransitionRequirement(*target, req)
5004
5005    def recordTransitionConsequence(
5006        self,
5007        consequence: base.Consequence
5008    ) -> None:
5009        """
5010        Records a transition consequence, which gets added to any
5011        existing consequences of the currently-relevant transition (the
5012        most-recently created or taken transition). A `JournalParseError`
5013        will be raised if there is no current transition.
5014        """
5015        target = self.currentTransitionTarget()
5016        if target is None:
5017            raise JournalParseError(
5018                "Cannot apply a consequence because there is no current"
5019                " transition."
5020            )
5021
5022        now = self.exploration.getSituation()
5023        now.graph.addConsequence(*target, consequence)
5024
5025    def recordReciprocalConsequence(
5026        self,
5027        consequence: base.Consequence
5028    ) -> None:
5029        """
5030        Like `recordTransitionConsequence` but applies the effect to the
5031        reciprocal of the current transition. Will cause a
5032        `JournalParseError` if the current transition has no reciprocal
5033        (e.g., it's an ending transition).
5034        """
5035        target = self.currentReciprocalTarget()
5036        if target is None:
5037            raise JournalParseError(
5038                "Cannot apply a reciprocal effect because there is no"
5039                " current transition, or it doesn't have a reciprocal."
5040            )
5041
5042        now = self.exploration.getSituation()
5043        now.graph.addConsequence(*target, consequence)
5044
5045    def recordAdditionalTransitionConsequence(
5046        self,
5047        consequence: base.Consequence,
5048        hideEffects: bool = True
5049    ) -> None:
5050        """
5051        Records the addition of a new consequence to the current
5052        relevant transition, while also triggering the effects of that
5053        consequence (but not the other effects of that transition, which
5054        we presume have just been applied already).
5055
5056        By default each effect added this way automatically gets the
5057        "hidden" property added to it, because the assumption is if it
5058        were a foreseeable effect, you would have added it to the
5059        transition before taking it. If you set `hideEffects` to
5060        `False`, this won't be done.
5061
5062        This modifies the current state but does not add a step to the
5063        exploration. It does NOT call `autoFinalizeExplorationStatuses`,
5064        which means that if a 'bounce' or 'goto' effect ends up making
5065        one or more decisions no-longer-active, they do NOT get their
5066        exploration statuses upgraded to 'explored'.
5067        """
5068        # Receive begin/end indices from `addConsequence` and send them
5069        # to `applyTransitionConsequence` to limit which # parts of the
5070        # expanded consequence are actually applied.
5071        currentTransition = self.currentTransitionTarget()
5072        if currentTransition is None:
5073            consRepr = self.parseFormat.unparseConsequence(consequence)
5074            raise JournalParseError(
5075                f"Can't apply an additional consequence to a transition"
5076                f" when there is no current transition. Got"
5077                f" consequence:\n{consRepr}"
5078            )
5079
5080        if hideEffects:
5081            for (index, item) in base.walkParts(consequence):
5082                if isinstance(item, dict) and 'value' in item:
5083                    assert 'hidden' in item
5084                    item = cast(base.Effect, item)
5085                    item['hidden'] = True
5086
5087        now = self.exploration.getSituation()
5088        begin, end = now.graph.addConsequence(
5089            *currentTransition,
5090            consequence
5091        )
5092        self.exploration.applyTransitionConsequence(
5093            *currentTransition,
5094            moveWhich=self.context['focus'],
5095            policy="specified",
5096            fromIndex=begin,
5097            toIndex=end
5098        )
5099        # This tracks trigger counts and obeys
5100        # charges/delays, unlike
5101        # applyExtraneousConsequence, but some effects
5102        # like 'bounce' still can't be properly applied
5103
5104    def recordTagStep(
5105        self,
5106        tag: base.Tag,
5107        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5108    ) -> None:
5109        """
5110        Records a tag to be applied to the current exploration step.
5111        """
5112        self.exploration.tagStep(tag, value)
5113
5114    def recordTagDecision(
5115        self,
5116        tag: base.Tag,
5117        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5118    ) -> None:
5119        """
5120        Records a tag to be applied to the current decision.
5121        """
5122        now = self.exploration.getSituation()
5123        now.graph.tagDecision(
5124            self.definiteDecisionTarget(),
5125            tag,
5126            value
5127        )
5128
5129    def recordTagTranstion(
5130        self,
5131        tag: base.Tag,
5132        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5133    ) -> None:
5134        """
5135        Records a tag to be applied to the most-recently-defined or
5136        -taken transition.
5137        """
5138        target = self.currentTransitionTarget()
5139        if target is None:
5140            raise JournalParseError(
5141                "Cannot tag a transition because there is no current"
5142                " transition."
5143            )
5144
5145        now = self.exploration.getSituation()
5146        now.graph.tagTransition(*target, tag, value)
5147
5148    def recordTagReciprocal(
5149        self,
5150        tag: base.Tag,
5151        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5152    ) -> None:
5153        """
5154        Records a tag to be applied to the reciprocal of the
5155        most-recently-defined or -taken transition.
5156        """
5157        target = self.currentReciprocalTarget()
5158        if target is None:
5159            raise JournalParseError(
5160                "Cannot tag a transition because there is no current"
5161                " transition."
5162            )
5163
5164        now = self.exploration.getSituation()
5165        now.graph.tagTransition(*target, tag, value)
5166
5167    def currentZoneAtLevel(self, level: int) -> base.Zone:
5168        """
5169        Returns a zone in the current graph that applies to the current
5170        decision which is at the specified hierarchy level. If there is
5171        no such zone, raises a `JournalParseError`. If there are
5172        multiple such zones, returns the zone which includes the fewest
5173        decisions, breaking ties alphabetically by zone name.
5174        """
5175        here = self.definiteDecisionTarget()
5176        graph = self.exploration.getSituation().graph
5177        ancestors = graph.zoneAncestors(here)
5178        candidates = [
5179            ancestor
5180            for ancestor in ancestors
5181            if graph.zoneHierarchyLevel(ancestor) == level
5182        ]
5183        if len(candidates) == 0:
5184            raise JournalParseError(
5185                (
5186                    f"Cannot find any level-{level} zones for the"
5187                    f" current decision {graph.identityOf(here)}. That"
5188                    f" decision is"
5189                ) + (
5190                    " in the following zones:"
5191                  + '\n'.join(
5192                        f"  level {graph.zoneHierarchyLevel(z)}: {z!r}"
5193                        for z in ancestors
5194                    )
5195                ) if len(ancestors) > 0 else (
5196                    " not in any zones."
5197                )
5198            )
5199        candidates.sort(
5200            key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone)
5201        )
5202        return candidates[0]
5203
5204    def recordTagZone(
5205        self,
5206        level: int,
5207        tag: base.Tag,
5208        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5209    ) -> None:
5210        """
5211        Records a tag to be applied to one of the zones that the current
5212        decision is in, at a specific hierarchy level. There must be at
5213        least one zone ancestor of the current decision at that hierarchy
5214        level; if there are multiple then the tag is applied to the
5215        smallest one, breaking ties by alphabetical order.
5216        """
5217        applyTo = self.currentZoneAtLevel(level)
5218        self.exploration.getSituation().graph.tagZone(applyTo, tag, value)
5219
5220    def recordAnnotateStep(
5221        self,
5222        *annotations: base.Annotation
5223    ) -> None:
5224        """
5225        Records annotations to be applied to the current exploration
5226        step.
5227        """
5228        self.exploration.annotateStep(annotations)
5229        pf = self.parseFormat
5230        now = self.exploration.getSituation()
5231        for a in annotations:
5232            if a.startswith("at:"):
5233                expects = pf.parseDecisionSpecifier(a[3:])
5234                if isinstance(expects, base.DecisionSpecifier):
5235                    if expects.domain is None and expects.zone is None:
5236                        expects = base.spliceDecisionSpecifiers(
5237                            expects,
5238                            self.decisionTargetSpecifier()
5239                        )
5240                eID = now.graph.getDecision(expects)
5241                primaryNow: Optional[base.DecisionID]
5242                if self.inRelativeMode:
5243                    primaryNow = self.definiteDecisionTarget()
5244                else:
5245                    primaryNow = now.state['primaryDecision']
5246                if eID is None:
5247                    self.warn(
5248                        f"'at' annotation expects position {expects!r}"
5249                        f" but that's not a valid decision specifier in"
5250                        f" the current graph."
5251                    )
5252                elif eID != primaryNow:
5253                    self.warn(
5254                        f"'at' annotation expects position {expects!r}"
5255                        f" which is decision"
5256                        f" {now.graph.identityOf(eID)}, but the current"
5257                        f" primary decision is"
5258                        f" {now.graph.identityOf(primaryNow)}"
5259                    )
5260            elif a.startswith("active:"):
5261                expects = pf.parseDecisionSpecifier(a[3:])
5262                eID = now.graph.getDecision(expects)
5263                atNow = base.combinedDecisionSet(now.state)
5264                if eID is None:
5265                    self.warn(
5266                        f"'active' annotation expects decision {expects!r}"
5267                        f" but that's not a valid decision specifier in"
5268                        f" the current graph."
5269                    )
5270                elif eID not in atNow:
5271                    self.warn(
5272                        f"'active' annotation expects decision {expects!r}"
5273                        f" which is {now.graph.identityOf(eID)}, but"
5274                        f" the current active position(s) is/are:"
5275                        f"\n{now.graph.namesListing(atNow)}"
5276                    )
5277            elif a.startswith("has:"):
5278                ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0]
5279                if (
5280                    isinstance(ea, tuple)
5281                and len(ea) == 2
5282                and isinstance(ea[0], base.Token)
5283                and isinstance(ea[1], base.TokenCount)
5284                ):
5285                    countNow = base.combinedTokenCount(now.state, ea[0])
5286                    if countNow != ea[1]:
5287                        self.warn(
5288                            f"'has' annotation expects {ea[1]} {ea[0]!r}"
5289                            f" token(s) but the current state has"
5290                            f" {countNow} of them."
5291                        )
5292                else:
5293                    self.warn(
5294                        f"'has' annotation expects tokens {a[4:]!r} but"
5295                        f" that's not a (token, count) pair."
5296                    )
5297            elif a.startswith("level:"):
5298                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5299                if (
5300                    isinstance(ea, tuple)
5301                and len(ea) == 3
5302                and ea[0] == 'skill'
5303                and isinstance(ea[1], base.Skill)
5304                and isinstance(ea[2], base.Level)
5305                ):
5306                    levelNow = base.getSkillLevel(now.state, ea[1])
5307                    if levelNow != ea[2]:
5308                        self.warn(
5309                            f"'level' annotation expects skill {ea[1]!r}"
5310                            f" to be at level {ea[2]} but the current"
5311                            f" level for that skill is {levelNow}."
5312                        )
5313                else:
5314                    self.warn(
5315                        f"'level' annotation expects skill {a[6:]!r} but"
5316                        f" that's not a (skill, level) pair."
5317                    )
5318            elif a.startswith("can:"):
5319                try:
5320                    req = pf.parseRequirement(a[4:])
5321                except parsing.ParseError:
5322                    self.warn(
5323                        f"'can' annotation expects requirement {a[4:]!r}"
5324                        f" but that's not parsable as a requirement."
5325                    )
5326                    req = None
5327                if req is not None:
5328                    ctx = base.genericContextForSituation(now)
5329                    if not req.satisfied(ctx):
5330                        self.warn(
5331                            f"'can' annotation expects requirement"
5332                            f" {req!r} to be satisfied but it's not in"
5333                            f" the current situation."
5334                        )
5335            elif a.startswith("state:"):
5336                ctx = base.genericContextForSituation(
5337                    now
5338                )
5339                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5340                if (
5341                    isinstance(ea, tuple)
5342                and len(ea) == 2
5343                and isinstance(ea[0], tuple)
5344                and len(ea[0]) == 4
5345                and (ea[0][0] is None or isinstance(ea[0][0], base.Domain))
5346                and (ea[0][1] is None or isinstance(ea[0][1], base.Zone))
5347                and (
5348                        ea[0][2] is None
5349                     or isinstance(ea[0][2], base.DecisionName)
5350                    )
5351                and isinstance(ea[0][3], base.MechanismName)
5352                and isinstance(ea[1], base.MechanismState)
5353                ):
5354                    mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom)
5355                    stateNow = base.stateOfMechanism(ctx, mID)
5356                    if not base.mechanismInStateOrEquivalent(
5357                        mID,
5358                        ea[1],
5359                        ctx
5360                    ):
5361                        self.warn(
5362                            f"'state' annotation expects mechanism {mID}"
5363                            f" {ea[0]!r} to be in state {ea[1]!r} but"
5364                            f" its current state is {stateNow!r} and no"
5365                            f" equivalence makes it count as being in"
5366                            f" state {ea[1]!r}."
5367                        )
5368                else:
5369                    self.warn(
5370                        f"'state' annotation expects mechanism state"
5371                        f" {a[6:]!r} but that's not a mechanism/state"
5372                        f" pair."
5373                    )
5374            elif a.startswith("exists:"):
5375                expects = pf.parseDecisionSpecifier(a[7:])
5376                try:
5377                    now.graph.resolveDecision(expects)
5378                except core.MissingDecisionError:
5379                    self.warn(
5380                        f"'exists' annotation expects decision"
5381                        f" {a[7:]!r} but that decision does not exist."
5382                    )
5383
5384    def recordAnnotateDecision(
5385        self,
5386        *annotations: base.Annotation
5387    ) -> None:
5388        """
5389        Records annotations to be applied to the current decision.
5390        """
5391        now = self.exploration.getSituation()
5392        now.graph.annotateDecision(self.definiteDecisionTarget(), annotations)
5393
5394    def recordAnnotateTranstion(
5395        self,
5396        *annotations: base.Annotation
5397    ) -> None:
5398        """
5399        Records annotations to be applied to the most-recently-defined
5400        or -taken transition.
5401        """
5402        target = self.currentTransitionTarget()
5403        if target is None:
5404            raise JournalParseError(
5405                "Cannot annotate a transition because there is no"
5406                " current transition."
5407            )
5408
5409        now = self.exploration.getSituation()
5410        now.graph.annotateTransition(*target, annotations)
5411
5412    def recordAnnotateReciprocal(
5413        self,
5414        *annotations: base.Annotation
5415    ) -> None:
5416        """
5417        Records annotations to be applied to the reciprocal of the
5418        most-recently-defined or -taken transition.
5419        """
5420        target = self.currentReciprocalTarget()
5421        if target is None:
5422            raise JournalParseError(
5423                "Cannot annotate a reciprocal because there is no"
5424                " current transition or because it doens't have a"
5425                " reciprocal."
5426            )
5427
5428        now = self.exploration.getSituation()
5429        now.graph.annotateTransition(*target, annotations)
5430
5431    def recordAnnotateZone(
5432        self,
5433        level,
5434        *annotations: base.Annotation
5435    ) -> None:
5436        """
5437        Records annotations to be applied to the zone at the specified
5438        hierarchy level which contains the current decision. If there are
5439        multiple such zones, it picks the smallest one, breaking ties
5440        alphabetically by zone name (see `currentZoneAtLevel`).
5441        """
5442        applyTo = self.currentZoneAtLevel(level)
5443        self.exploration.getSituation().graph.annotateZone(
5444            applyTo,
5445            annotations
5446        )
5447
5448    def recordContextSwap(
5449        self,
5450        targetContext: Optional[base.FocalContextName]
5451    ) -> None:
5452        """
5453        Records a swap of the active focal context, and/or a swap into
5454        "common"-context mode where all effects modify the common focal
5455        context instead of the active one. Use `None` as the argument to
5456        swap to common mode; use another specific value so swap to
5457        normal mode and set that context as the active one.
5458
5459        In relative mode, swaps the active context without adding an
5460        exploration step. Swapping into the common context never results
5461        in a new exploration step.
5462        """
5463        if targetContext is None:
5464            self.context['context'] = "common"
5465        else:
5466            self.context['context'] = "active"
5467            e = self.getExploration()
5468            if self.inRelativeMode:
5469                e.setActiveContext(targetContext)
5470            else:
5471                e.advanceSituation(('swap', targetContext))
5472
5473    def recordZone(self, level: int, zone: base.Zone) -> None:
5474        """
5475        Records a new current zone to be swapped with the zone(s) at the
5476        specified hierarchy level for the current decision target. See
5477        `core.DiscreteExploration.reZone` and
5478        `core.DecisionGraph.replaceZonesInHierarchy` for details on what
5479        exactly happens; the summary is that the zones at the specified
5480        hierarchy level are replaced with the provided zone (which is
5481        created if necessary) and their children are re-parented onto
5482        the provided zone, while that zone is also set as a child of
5483        their parents.
5484
5485        Does the same thing in relative mode as in normal mode.
5486        """
5487        self.exploration.reZone(
5488            zone,
5489            self.definiteDecisionTarget(),
5490            level
5491        )
5492
5493    def recordUnify(
5494        self,
5495        merge: base.AnyDecisionSpecifier,
5496        mergeInto: Optional[base.AnyDecisionSpecifier] = None
5497    ) -> None:
5498        """
5499        Records a unification between two decisions. This marks an
5500        observation that they are actually the same decision and it
5501        merges them. If only one decision is given the current decision
5502        is merged into that one. After the merge, the first decision (or
5503        the current decision if only one was given) will no longer
5504        exist.
5505
5506        If one of the merged decisions was the current position in a
5507        singular-focalized domain, or one of the current positions in a
5508        plural- or spreading-focalized domain, the merged decision will
5509        replace it as a current decision after the merge, and this
5510        happens even when in relative mode. The target decision is also
5511        updated if it needs to be.
5512
5513        A `TransitionCollisionError` will be raised if the two decisions
5514        have outgoing transitions that share a name.
5515
5516        Logs a `JournalParseWarning` if the two decisions were in
5517        different zones.
5518
5519        Any transitions between the two merged decisions will remain in
5520        place as actions.
5521
5522        TODO: Option for removing self-edges after the merge? Option for
5523        doing that for just effect-less edges?
5524        """
5525        if mergeInto is None:
5526            mergeInto = merge
5527            merge = self.definiteDecisionTarget()
5528
5529        if isinstance(merge, str):
5530            merge = self.parseFormat.parseDecisionSpecifier(merge)
5531
5532        if isinstance(mergeInto, str):
5533            mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto)
5534
5535        now = self.exploration.getSituation()
5536
5537        if not isinstance(merge, base.DecisionID):
5538            merge = now.graph.resolveDecision(merge)
5539
5540        merge = cast(base.DecisionID, merge)
5541
5542        now.graph.mergeDecisions(merge, mergeInto)
5543
5544        mergedID = now.graph.resolveDecision(mergeInto)
5545
5546        # Update FocalContexts & ObservationContexts as necessary
5547        self.cleanupContexts(remapped={merge: mergedID})
5548
5549    def recordUnifyTransition(self, target: base.Transition) -> None:
5550        """
5551        Records a unification between the most-recently-defined or
5552        -taken transition and the specified transition (which must be
5553        outgoing from the same decision). This marks an observation that
5554        two transitions are actually the same transition and it merges
5555        them.
5556
5557        After the merge, the target transition will still exist but the
5558        previously most-recent transition will have been deleted.
5559
5560        Their reciprocals will also be merged.
5561
5562        A `JournalParseError` is raised if there is no most-recent
5563        transition.
5564        """
5565        now = self.exploration.getSituation()
5566        graph = now.graph
5567        affected = self.currentTransitionTarget()
5568        if affected is None or affected[1] is None:
5569            raise JournalParseError(
5570                "Cannot unify transitions: there is no current"
5571                " transition."
5572            )
5573
5574        decision, transition = affected
5575
5576        # If they don't share a target, then the current transition must
5577        # lead to an unknown node, which we will dispose of
5578        destination = graph.getDestination(decision, transition)
5579        if destination is None:
5580            raise JournalParseError(
5581                f"Cannot unify transitions: transition"
5582                f" {transition!r} at decision"
5583                f" {graph.identityOf(decision)} has no destination."
5584            )
5585
5586        finalDestination = graph.getDestination(decision, target)
5587        if finalDestination is None:
5588            raise JournalParseError(
5589                f"Cannot unify transitions: transition"
5590                f" {target!r} at decision {graph.identityOf(decision)}"
5591                f" has no destination."
5592            )
5593
5594        if destination != finalDestination:
5595            if graph.isConfirmed(destination):
5596                raise JournalParseError(
5597                    f"Cannot unify transitions: destination"
5598                    f" {graph.identityOf(destination)} of transition"
5599                    f" {transition!r} at decision"
5600                    f" {graph.identityOf(decision)} is not an"
5601                    f" unconfirmed decision."
5602                )
5603            # Retarget and delete the unknown node that we abandon
5604            # TODO: Merge nodes instead?
5605            now.graph.retargetTransition(
5606                decision,
5607                transition,
5608                finalDestination
5609            )
5610            now.graph.removeDecision(destination)
5611
5612        # Now we can merge transitions
5613        now.graph.mergeTransitions(decision, transition, target)
5614
5615        # Update targets if they were merged
5616        self.cleanupContexts(
5617            remappedTransitions={
5618                (decision, transition): (decision, target)
5619            }
5620        )
5621
5622    def recordUnifyReciprocal(
5623        self,
5624        target: base.Transition
5625    ) -> None:
5626        """
5627        Records a unification between the reciprocal of the
5628        most-recently-defined or -taken transition and the specified
5629        transition, which must be outgoing from the current transition's
5630        destination. This marks an observation that two transitions are
5631        actually the same transition and it merges them, deleting the
5632        original reciprocal. Note that the current transition will also
5633        be merged with the reciprocal of the target.
5634
5635        A `JournalParseError` is raised if there is no current
5636        transition, or if it does not have a reciprocal.
5637        """
5638        now = self.exploration.getSituation()
5639        graph = now.graph
5640        affected = self.currentReciprocalTarget()
5641        if affected is None or affected[1] is None:
5642            raise JournalParseError(
5643                "Cannot unify transitions: there is no current"
5644                " transition."
5645            )
5646
5647        decision, transition = affected
5648
5649        destination = graph.destination(decision, transition)
5650        reciprocal = graph.getReciprocal(decision, transition)
5651        if reciprocal is None:
5652            raise JournalParseError(
5653                "Cannot unify reciprocal: there is no reciprocal of the"
5654                " current transition."
5655            )
5656
5657        # If they don't share a target, then the current transition must
5658        # lead to an unknown node, which we will dispose of
5659        finalDestination = graph.getDestination(destination, target)
5660        if finalDestination is None:
5661            raise JournalParseError(
5662                f"Cannot unify reciprocal: transition"
5663                f" {target!r} at decision"
5664                f" {graph.identityOf(destination)} has no destination."
5665            )
5666
5667        if decision != finalDestination:
5668            if graph.isConfirmed(decision):
5669                raise JournalParseError(
5670                    f"Cannot unify reciprocal: destination"
5671                    f" {graph.identityOf(decision)} of transition"
5672                    f" {reciprocal!r} at decision"
5673                    f" {graph.identityOf(destination)} is not an"
5674                    f" unconfirmed decision."
5675                )
5676            # Retarget and delete the unknown node that we abandon
5677            # TODO: Merge nodes instead?
5678            graph.retargetTransition(
5679                destination,
5680                reciprocal,
5681                finalDestination
5682            )
5683            graph.removeDecision(decision)
5684
5685        # Actually merge the transitions
5686        graph.mergeTransitions(destination, reciprocal, target)
5687
5688        # Update targets if they were merged
5689        self.cleanupContexts(
5690            remappedTransitions={
5691                (decision, transition): (decision, target)
5692            }
5693        )
5694
5695    def recordObviate(
5696        self,
5697        transition: base.Transition,
5698        otherDecision: base.AnyDecisionSpecifier,
5699        otherTransition: base.Transition
5700    ) -> None:
5701        """
5702        Records the obviation of a transition at another decision. This
5703        is the observation that a specific transition at the current
5704        decision is the reciprocal of a different transition at another
5705        decision which previously led to an unknown area. The difference
5706        between this and `recordReturn` is that `recordReturn` logs
5707        movement across the newly-connected transition, while this
5708        leaves the player at their original decision (and does not even
5709        add a step to the current exploration).
5710
5711        Both transitions will be created if they didn't already exist.
5712
5713        In relative mode does the same thing and doesn't move the current
5714        decision across the transition updated.
5715
5716        If the destination is unknown, it will remain unknown after this
5717        operation.
5718        """
5719        now = self.exploration.getSituation()
5720        graph = now.graph
5721        here = self.definiteDecisionTarget()
5722
5723        if isinstance(otherDecision, str):
5724            otherDecision = self.parseFormat.parseDecisionSpecifier(
5725                otherDecision
5726            )
5727
5728        # If we started with a name or some other kind of decision
5729        # specifier, replace missing domain and/or zone info with info
5730        # from the current decision.
5731        if isinstance(otherDecision, base.DecisionSpecifier):
5732            otherDecision = base.spliceDecisionSpecifiers(
5733                otherDecision,
5734                self.decisionTargetSpecifier()
5735            )
5736
5737        otherDestination = graph.getDestination(
5738            otherDecision,
5739            otherTransition
5740        )
5741        if otherDestination is not None:
5742            if graph.isConfirmed(otherDestination):
5743                raise JournalParseError(
5744                    f"Cannot obviate transition {otherTransition!r} at"
5745                    f" decision {graph.identityOf(otherDecision)}: that"
5746                    f" transition leads to decision"
5747                    f" {graph.identityOf(otherDestination)} which has"
5748                    f" already been visited."
5749                )
5750        else:
5751            # We must create the other destination
5752            graph.addUnexploredEdge(otherDecision, otherTransition)
5753
5754        destination = graph.getDestination(here, transition)
5755        if destination is not None:
5756            if graph.isConfirmed(destination):
5757                raise JournalParseError(
5758                    f"Cannot obviate using transition {transition!r} at"
5759                    f" decision {graph.identityOf(here)}: that"
5760                    f" transition leads to decision"
5761                    f" {graph.identityOf(destination)} which is not an"
5762                    f" unconfirmed decision."
5763                )
5764        else:
5765            # we need to create it
5766            graph.addUnexploredEdge(here, transition)
5767
5768        # Track exploration status of destination (because
5769        # `replaceUnconfirmed` will overwrite it but we want to preserve
5770        # it in this case.
5771        if otherDecision is not None:
5772            prevStatus = base.explorationStatusOf(now, otherDecision)
5773
5774        # Now connect the transitions and clean up the unknown nodes
5775        graph.replaceUnconfirmed(
5776            here,
5777            transition,
5778            otherDecision,
5779            otherTransition
5780        )
5781        # Restore exploration status
5782        base.setExplorationStatus(now, otherDecision, prevStatus)
5783
5784        # Update context
5785        self.context['transition'] = (here, transition)
5786
5787    def cleanupContexts(
5788        self,
5789        remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None,
5790        remappedTransitions: Optional[
5791            Dict[
5792                Tuple[base.DecisionID, base.Transition],
5793                Tuple[base.DecisionID, base.Transition]
5794            ]
5795        ] = None
5796    ) -> None:
5797        """
5798        Checks the validity of context decision and transition entries,
5799        and sets them to `None` in situations where they are no longer
5800        valid, affecting both the current and stored contexts.
5801
5802        Also updates position information in focal contexts in the
5803        current exploration step.
5804
5805        If a `remapped` dictionary is provided, decisions in the keys of
5806        that dictionary will be replaced with the corresponding value
5807        before being checked.
5808
5809        Similarly a `remappedTransitions` dicitonary may provide info on
5810        renamed transitions using (`base.DecisionID`, `base.Transition`)
5811        pairs as both keys and values.
5812        """
5813        if remapped is None:
5814            remapped = {}
5815
5816        if remappedTransitions is None:
5817            remappedTransitions = {}
5818
5819        # Fix broken position information in the current focal contexts
5820        now = self.exploration.getSituation()
5821        graph = now.graph
5822        state = now.state
5823        for ctx in (
5824            state['common'],
5825            state['contexts'][state['activeContext']]
5826        ):
5827            active = ctx['activeDecisions']
5828            for domain in active:
5829                aVal = active[domain]
5830                if isinstance(aVal, base.DecisionID):
5831                    if aVal in remapped:  # check for remap
5832                        aVal = remapped[aVal]
5833                        active[domain] = aVal
5834                    if graph.getDecision(aVal) is None: # Ultimately valid?
5835                        active[domain] = None
5836                elif isinstance(aVal, dict):
5837                    for fpName in aVal:
5838                        fpVal = aVal[fpName]
5839                        if fpVal is None:
5840                            aVal[fpName] = None
5841                        elif fpVal in remapped:  # check for remap
5842                            aVal[fpName] = remapped[fpVal]
5843                        elif graph.getDecision(fpVal) is None:  # valid?
5844                            aVal[fpName] = None
5845                elif isinstance(aVal, set):
5846                    for r in remapped:
5847                        if r in aVal:
5848                            aVal.remove(r)
5849                            aVal.add(remapped[r])
5850                    discard = []
5851                    for dID in aVal:
5852                        if graph.getDecision(dID) is None:
5853                            discard.append(dID)
5854                    for dID in discard:
5855                        aVal.remove(dID)
5856                elif aVal is not None:
5857                    raise RuntimeError(
5858                        f"Invalid active decisions for domain"
5859                        f" {repr(domain)}: {repr(aVal)}"
5860                    )
5861
5862        # Fix up our ObservationContexts
5863        fix = [self.context]
5864        if self.storedContext is not None:
5865            fix.append(self.storedContext)
5866
5867        graph = self.exploration.getSituation().graph
5868        for obsCtx in fix:
5869            cdID = obsCtx['decision']
5870            if cdID in remapped:
5871                cdID = remapped[cdID]
5872                obsCtx['decision'] = cdID
5873
5874            if cdID not in graph:
5875                obsCtx['decision'] = None
5876
5877            transition = obsCtx['transition']
5878            if transition is not None:
5879                tSourceID = transition[0]
5880                if tSourceID in remapped:
5881                    tSourceID = remapped[tSourceID]
5882                    obsCtx['transition'] = (tSourceID, transition[1])
5883
5884                if transition in remappedTransitions:
5885                    obsCtx['transition'] = remappedTransitions[transition]
5886
5887                tDestID = graph.getDestination(tSourceID, transition[1])
5888                if tDestID is None:
5889                    obsCtx['transition'] = None
5890
5891    def recordExtinguishDecision(
5892        self,
5893        target: base.AnyDecisionSpecifier
5894    ) -> None:
5895        """
5896        Records the deletion of a decision. The decision and all
5897        transitions connected to it will be removed from the current
5898        graph. Does not create a new exploration step. If the current
5899        position is deleted, the position will be set to `None`, or if
5900        we're in relative mode, the decision target will be set to
5901        `None` if it gets deleted. Likewise, all stored and/or current
5902        transitions which no longer exist are erased to `None`.
5903        """
5904        # Erase target if it's going to be removed
5905        now = self.exploration.getSituation()
5906
5907        if isinstance(target, str):
5908            target = self.parseFormat.parseDecisionSpecifier(target)
5909
5910        # TODO: Do we need to worry about the node being part of any
5911        # focal context data structures?
5912
5913        # Actually remove it
5914        now.graph.removeDecision(target)
5915
5916        # Clean up our contexts
5917        self.cleanupContexts()
5918
5919    def recordExtinguishTransition(
5920        self,
5921        source: base.AnyDecisionSpecifier,
5922        target: base.Transition,
5923        deleteReciprocal: bool = True
5924    ) -> None:
5925        """
5926        Records the deletion of a named transition coming from a
5927        specific source. The reciprocal will also be removed, unless
5928        `deleteReciprocal` is set to False. If `deleteReciprocal` is
5929        used and this results in the complete isolation of an unknown
5930        node, that node will be deleted as well. Cleans up any saved
5931        transition targets that are no longer valid by setting them to
5932        `None`. Does not create a graph step.
5933        """
5934        now = self.exploration.getSituation()
5935        graph = now.graph
5936        dest = graph.destination(source, target)
5937
5938        # Remove the transition
5939        graph.removeTransition(source, target, deleteReciprocal)
5940
5941        # Remove the old destination if it's unconfirmed and no longer
5942        # connected anywhere
5943        if (
5944            not graph.isConfirmed(dest)
5945        and len(graph.destinationsFrom(dest)) == 0
5946        ):
5947            graph.removeDecision(dest)
5948
5949        # Clean up our contexts
5950        self.cleanupContexts()
5951
5952    def recordComplicate(
5953        self,
5954        target: base.Transition,
5955        newDecision: base.DecisionName,  # TODO: Allow zones/domain here
5956        newReciprocal: Optional[base.Transition],
5957        newReciprocalReciprocal: Optional[base.Transition]
5958    ) -> base.DecisionID:
5959        """
5960        Records the complication of a transition and its reciprocal into
5961        a new decision. The old transition and its old reciprocal (if
5962        there was one) both point to the new decision. The
5963        `newReciprocal` becomes the new reciprocal of the original
5964        transition, and the `newReciprocalReciprocal` becomes the new
5965        reciprocal of the old reciprocal. Either may be set explicitly to
5966        `None` to leave the corresponding new transition without a
5967        reciprocal (but they don't default to `None`). If there was no
5968        old reciprocal, but `newReciprocalReciprocal` is specified, then
5969        that transition is created linking the new node to the old
5970        destination, without a reciprocal.
5971
5972        The current decision & transition information is not updated.
5973
5974        Returns the decision ID for the new node.
5975        """
5976        now = self.exploration.getSituation()
5977        graph = now.graph
5978        here = self.definiteDecisionTarget()
5979        domain = graph.domainFor(here)
5980
5981        oldDest = graph.destination(here, target)
5982        oldReciprocal = graph.getReciprocal(here, target)
5983
5984        # Create the new decision:
5985        newID = graph.addDecision(newDecision, domain=domain)
5986        # Note that the new decision is NOT an unknown decision
5987        # We copy the exploration status from the current decision
5988        self.exploration.setExplorationStatus(
5989            newID,
5990            self.exploration.getExplorationStatus(here)
5991        )
5992        # Copy over zone info
5993        for zp in graph.zoneParents(here):
5994            graph.addDecisionToZone(newID, zp)
5995
5996        # Retarget the transitions
5997        graph.retargetTransition(
5998            here,
5999            target,
6000            newID,
6001            swapReciprocal=False
6002        )
6003        if oldReciprocal is not None:
6004            graph.retargetTransition(
6005                oldDest,
6006                oldReciprocal,
6007                newID,
6008                swapReciprocal=False
6009            )
6010
6011        # Add a new reciprocal edge
6012        if newReciprocal is not None:
6013            graph.addTransition(newID, newReciprocal, here)
6014            graph.setReciprocal(here, target, newReciprocal)
6015
6016        # Add a new double-reciprocal edge (even if there wasn't a
6017        # reciprocal before)
6018        if newReciprocalReciprocal is not None:
6019            graph.addTransition(
6020                newID,
6021                newReciprocalReciprocal,
6022                oldDest
6023            )
6024            if oldReciprocal is not None:
6025                graph.setReciprocal(
6026                    oldDest,
6027                    oldReciprocal,
6028                    newReciprocalReciprocal
6029                )
6030
6031        return newID
6032
6033    def recordRevert(
6034        self,
6035        slot: base.SaveSlot,
6036        aspects: Set[str],
6037        decisionType: base.DecisionType = 'active'
6038    ) -> None:
6039        """
6040        Records a reversion to a previous state (possibly for only some
6041        aspects of the current state). See `base.revertedState` for the
6042        allowed values and meanings of strings in the aspects set.
6043        Uses the specified decision type, or 'active' by default.
6044
6045        Reversion counts as an exploration step.
6046
6047        This sets the current decision to the primary decision for the
6048        reverted state (which might be `None` in some cases) and sets
6049        the current transition to None.
6050        """
6051        self.exploration.revert(slot, aspects, decisionType=decisionType)
6052        newPrimary = self.exploration.getSituation().state['primaryDecision']
6053        self.context['decision'] = newPrimary
6054        self.context['transition'] = None
6055
6056    def recordFulfills(
6057        self,
6058        requirement: Union[str, base.Requirement],
6059        fulfilled: Union[
6060            base.Capability,
6061            Tuple[base.MechanismID, base.MechanismState]
6062        ]
6063    ) -> None:
6064        """
6065        Records the observation that a certain requirement fulfills the
6066        same role as (i.e., is equivalent to) a specific capability, or a
6067        specific mechanism being in a specific state. Transitions that
6068        require that capability or mechanism state will count as
6069        traversable even if that capability is not obtained or that
6070        mechanism is in another state, as long as the requirement for the
6071        fulfillment is satisfied. If multiple equivalences are
6072        established, any one of them being satisfied will count as that
6073        capability being obtained (or the mechanism being in the
6074        specified state). Note that if a circular dependency is created,
6075        the capability or mechanism (unless actually obtained or in the
6076        target state) will be considered as not being obtained (or in the
6077        target state) during recursive checks.
6078        """
6079        if isinstance(requirement, str):
6080            requirement = self.parseFormat.parseRequirement(requirement)
6081
6082        self.getExploration().getSituation().graph.addEquivalence(
6083            requirement,
6084            fulfilled
6085        )
6086
6087    def recordFocusOn(
6088        self,
6089        newFocalPoint: base.FocalPointName,
6090        inDomain: Optional[base.Domain] = None,
6091        inCommon: bool = False
6092    ):
6093        """
6094        Records a swap to a new focal point, setting that focal point as
6095        the active focal point in the observer's current domain, or in
6096        the specified domain if one is specified.
6097
6098        A `JournalParseError` is raised if the current/specified domain
6099        does not have plural focalization. If it doesn't have a focal
6100        point with that name, then one is created and positioned at the
6101        observer's current decision (which must be in the appropriate
6102        domain).
6103
6104        If `inCommon` is set to `True` (default is `False`) then the
6105        changes will be applied to the common context instead of the
6106        active context.
6107
6108        Note that this does *not* make the target domain active; use
6109        `recordDomainFocus` for that if you need to.
6110        """
6111        if inDomain is None:
6112            inDomain = self.context['domain']
6113
6114        if inCommon:
6115            ctx = self.getExploration().getCommonContext()
6116        else:
6117            ctx = self.getExploration().getActiveContext()
6118
6119        if ctx['focalization'].get('domain') != 'plural':
6120            raise JournalParseError(
6121                f"Domain {inDomain!r} does not exist or does not have"
6122                f" plural focalization, so we can't set a focal point"
6123                f" in it."
6124            )
6125
6126        focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {})
6127        if not isinstance(focalPointMap, dict):
6128            raise RuntimeError(
6129                f"Plural-focalized domain {inDomain!r} has"
6130                f" non-dictionary active"
6131                f" decisions:\n{repr(focalPointMap)}"
6132            )
6133
6134        if newFocalPoint not in focalPointMap:
6135            focalPointMap[newFocalPoint] = self.context['decision']
6136
6137        self.context['focus'] = newFocalPoint
6138        self.context['decision'] = focalPointMap[newFocalPoint]
6139
6140    def recordDomainUnfocus(
6141        self,
6142        domain: base.Domain,
6143        inCommon: bool = False
6144    ):
6145        """
6146        Records a domain losing focus. Does not raise an error if the
6147        target domain was not active (in that case, it doesn't need to
6148        do anything).
6149
6150        If `inCommon` is set to `True` (default is `False`) then the
6151        domain changes will be applied to the common context instead of
6152        the active context.
6153        """
6154        if inCommon:
6155            ctx = self.getExploration().getCommonContext()
6156        else:
6157            ctx = self.getExploration().getActiveContext()
6158
6159        try:
6160            ctx['activeDomains'].remove(domain)
6161        except KeyError:
6162            pass
6163
6164    def recordDomainFocus(
6165        self,
6166        domain: base.Domain,
6167        exclusive: bool = False,
6168        inCommon: bool = False
6169    ):
6170        """
6171        Records a domain gaining focus, activating that domain in the
6172        current focal context and setting it as the observer's current
6173        domain. If the domain named doesn't exist yet, it will be
6174        created first (with default focalization) and then focused.
6175
6176        If `exclusive` is set to `True` (default is `False`) then all
6177        other active domains will be deactivated.
6178
6179        If `inCommon` is set to `True` (default is `False`) then the
6180        domain changes will be applied to the common context instead of
6181        the active context.
6182        """
6183        if inCommon:
6184            ctx = self.getExploration().getCommonContext()
6185        else:
6186            ctx = self.getExploration().getActiveContext()
6187
6188        if exclusive:
6189            ctx['activeDomains'] = set()
6190
6191        if domain not in ctx['focalization']:
6192            self.recordNewDomain(domain, inCommon=inCommon)
6193        else:
6194            ctx['activeDomains'].add(domain)
6195
6196        self.context['domain'] = domain
6197
6198    def recordNewDomain(
6199        self,
6200        domain: base.Domain,
6201        focalization: base.DomainFocalization = "singular",
6202        inCommon: bool = False
6203    ):
6204        """
6205        Records a new domain, setting it up with the specified
6206        focalization. Sets that domain as an active domain and as the
6207        journal's current domain so that subsequent entries will create
6208        decisions in that domain. However, it does not activate any
6209        decisions within that domain.
6210
6211        Raises a `JournalParseError` if the specified domain already
6212        exists.
6213
6214        If `inCommon` is set to `True` (default is `False`) then the new
6215        domain will be made active in the common context instead of the
6216        active context.
6217        """
6218        if inCommon:
6219            ctx = self.getExploration().getCommonContext()
6220        else:
6221            ctx = self.getExploration().getActiveContext()
6222
6223        if domain in ctx['focalization']:
6224            raise JournalParseError(
6225                f"Cannot create domain {domain!r}: that domain already"
6226                f" exists."
6227            )
6228
6229        ctx['focalization'][domain] = focalization
6230        ctx['activeDecisions'][domain] = None
6231        ctx['activeDomains'].add(domain)
6232        self.context['domain'] = domain
6233
6234    def relative(
6235        self,
6236        where: Optional[base.AnyDecisionSpecifier] = None,
6237        transition: Optional[base.Transition] = None,
6238    ) -> None:
6239        """
6240        Enters 'relative mode' where the exploration ceases to add new
6241        steps but edits can still be performed on the current graph. This
6242        also changes the current decision/transition settings so that
6243        edits can be applied anywhere. It can accept 0, 1, or 2
6244        arguments. With 0 arguments, it simply enters relative mode but
6245        maintains the current position as the target decision and the
6246        last-taken or last-created transition as the target transition
6247        (note that that transition usually originates at a different
6248        decision). With 1 argument, it sets the target decision to the
6249        decision named, and sets the target transition to None. With 2
6250        arguments, it sets the target decision to the decision named, and
6251        the target transition to the transition named, which must
6252        originate at that target decision. If the first argument is None,
6253        the current decision is used.
6254
6255        If given the name of a decision which does not yet exist, it will
6256        create that decision in the current graph, disconnected from the
6257        rest of the graph. In that case, it is an error to also supply a
6258        transition to target (you can use other commands once in relative
6259        mode to build more transitions and decisions out from the
6260        newly-created decision).
6261
6262        When called in relative mode, it updates the current position
6263        and/or decision, or if called with no arguments, it exits
6264        relative mode. When exiting relative mode, the current decision
6265        is set back to the graph's current position, and the current
6266        transition is set to whatever it was before relative mode was
6267        entered.
6268
6269        Raises a `TypeError` if a transition is specified without
6270        specifying a decision. Raises a `ValueError` if given no
6271        arguments and the exploration does not have a current position.
6272        Also raises a `ValueError` if told to target a specific
6273        transition which does not exist.
6274
6275        TODO: Example here!
6276        """
6277        # TODO: Not this?
6278        if where is None:
6279            if transition is None and self.inRelativeMode:
6280                # If we're in relative mode, cancel it
6281                self.inRelativeMode = False
6282
6283                # Here we restore saved sate
6284                if self.storedContext is None:
6285                    raise RuntimeError(
6286                        "No stored context despite being in relative"
6287                        "mode."
6288                    )
6289                self.context = self.storedContext
6290                self.storedContext = None
6291
6292            else:
6293                # Enter or stay in relative mode and set up the current
6294                # decision/transition as the targets
6295
6296                # Ensure relative mode
6297                self.inRelativeMode = True
6298
6299                # Store state
6300                self.storedContext = self.context
6301                where = self.storedContext['decision']
6302                if where is None:
6303                    raise ValueError(
6304                        "Cannot enter relative mode at the current"
6305                        " position because there is no current"
6306                        " position."
6307                    )
6308
6309                self.context = observationContext(
6310                    context=self.storedContext['context'],
6311                    domain=self.storedContext['domain'],
6312                    focus=self.storedContext['focus'],
6313                    decision=where,
6314                    transition=(
6315                        None
6316                        if transition is None
6317                        else (where, transition)
6318                    )
6319                )
6320
6321        else: # we have at least a decision to target
6322            # If we're entering relative mode instead of just changing
6323            # focus, we need to set up the current transition if no
6324            # transition was specified.
6325            entering: Optional[
6326                Tuple[
6327                    base.ContextSpecifier,
6328                    base.Domain,
6329                    Optional[base.FocalPointName]
6330                ]
6331            ] = None
6332            if not self.inRelativeMode:
6333                # We'll be entering relative mode, so store state
6334                entering = (
6335                    self.context['context'],
6336                    self.context['domain'],
6337                    self.context['focus']
6338                )
6339                self.storedContext = self.context
6340                if transition is None:
6341                    oldTransitionPair = self.context['transition']
6342                    if oldTransitionPair is not None:
6343                        oldBase, oldTransition = oldTransitionPair
6344                        if oldBase == where:
6345                            transition = oldTransition
6346
6347            # Enter (or stay in) relative mode
6348            self.inRelativeMode = True
6349
6350            now = self.exploration.getSituation()
6351            whereID: Optional[base.DecisionID]
6352            whereSpec: Optional[base.DecisionSpecifier] = None
6353            if isinstance(where, str):
6354                where = self.parseFormat.parseDecisionSpecifier(where)
6355                # might turn it into a DecisionID
6356
6357            if isinstance(where, base.DecisionID):
6358                whereID = where
6359            elif isinstance(where, base.DecisionSpecifier):
6360                # Add in current zone + domain info if those things
6361                # aren't explicit
6362                if self.currentDecisionTarget() is not None:
6363                    where = base.spliceDecisionSpecifiers(
6364                        where,
6365                        self.decisionTargetSpecifier()
6366                    )
6367                elif where.domain is None:
6368                    # Splice in current domain if needed
6369                    where = base.DecisionSpecifier(
6370                        domain=self.context['domain'],
6371                        zone=where.zone,
6372                        name=where.name
6373                    )
6374                whereID = now.graph.getDecision(where)  # might be None
6375                whereSpec = where
6376            else:
6377                raise TypeError(f"Invalid decision specifier: {where!r}")
6378
6379            # Create a new decision if necessary
6380            if whereID is None:
6381                if transition is not None:
6382                    raise TypeError(
6383                        f"Cannot specify a target transition when"
6384                        f" entering relative mode at previously"
6385                        f" non-existent decision"
6386                        f" {now.graph.identityOf(where)}."
6387                    )
6388                assert whereSpec is not None
6389                whereID = now.graph.addDecision(
6390                    whereSpec.name,
6391                    domain=whereSpec.domain
6392                )
6393                if whereSpec.zone is not None:
6394                    now.graph.addDecisionToZone(whereID, whereSpec.zone)
6395
6396            # Create the new context if we're entering relative mode
6397            if entering is not None:
6398                self.context = observationContext(
6399                    context=entering[0],
6400                    domain=entering[1],
6401                    focus=entering[2],
6402                    decision=whereID,
6403                    transition=(
6404                        None
6405                        if transition is None
6406                        else (whereID, transition)
6407                    )
6408                )
6409
6410            # Target the specified decision
6411            self.context['decision'] = whereID
6412
6413            # Target the specified transition
6414            if transition is not None:
6415                self.context['transition'] = (whereID, transition)
6416                if now.graph.getDestination(where, transition) is None:
6417                    raise ValueError(
6418                        f"Cannot target transition {transition!r} at"
6419                        f" decision {now.graph.identityOf(where)}:"
6420                        f" there is no such transition."
6421                    )
6422            # otherwise leave self.context['transition'] alone

Keeps track of extra state needed when parsing a journal in order to produce a core.DiscreteExploration object. The methods of this class act as an API for constructing explorations that have several special properties. The API is designed to allow journal entries (which represent specific observations/events during an exploration) to be directly accumulated into an exploration object, including entries which apply to things like the most-recent-decision or -transition.

You can use the convertJournal function to handle things instead, since that function creates and manages a JournalObserver object for you.

The basic usage is as follows:

  1. Create a JournalObserver, optionally specifying a custom ParseFormat.
  2. Repeatedly either:
    • Call record* API methods corresponding to specific entries observed or...
    • Call JournalObserver.observe to parse one or more journal blocks from a string and call the appropriate methods automatically.
  3. Call JournalObserver.getExploration to retrieve the core.DiscreteExploration object that's been created.

You can just call convertJournal to do all of these things at once.

Notes:

  • JournalObserver.getExploration may be called at any time to get the exploration object constructed so far, and that that object (unless it's None) will always be the same object (which gets modified as entries are recorded). Modifying this object directly is possible for making changes not available via the API, but must be done carefully, as there are important conventions around things like decision names that must be respected if the API functions need to keep working.
  • To get the latest graph or state, simply use the core.DiscreteExploration.getSituation() method of the JournalObserver.getExploration result.

Examples

>>> obs = JournalObserver()
>>> e = obs.getExploration()
>>> len(e) # blank starting state
1
>>> e.getActiveDecisions(0)  # no active decisions before starting
set()
>>> obs.definiteDecisionTarget()
Traceback (most recent call last):
...
exploration.core.MissingDecisionError...
>>> obs.currentDecisionTarget() is None
True
>>> # We start by using the record* methods...
>>> obs.recordStart("Start")
>>> obs.definiteDecisionTarget()
0
>>> obs.recordObserve("bottom")
>>> obs.definiteDecisionTarget()
0
>>> len(e) # blank + started states
2
>>> e.getActiveDecisions(1)
{0}
>>> obs.recordExplore("left", "West", "right")
>>> obs.definiteDecisionTarget()
2
>>> len(e) # starting states + one step
3
>>> e.getActiveDecisions(1)
{0}
>>> e.movementAtStep(1)
(0, 'left', 2)
>>> e.getActiveDecisions(2)
{2}
>>> e.getActiveDecisions()
{2}
>>> e.getSituation().graph.nameFor(list(e.getActiveDecisions())[0])
'West'
>>> obs.recordRetrace("right")  # back at Start
>>> obs.definiteDecisionTarget()
0
>>> len(e) # starting states + two steps
4
>>> e.getActiveDecisions(1)
{0}
>>> e.movementAtStep(1)
(0, 'left', 2)
>>> e.getActiveDecisions(2)
{2}
>>> e.movementAtStep(2)
(2, 'right', 0)
>>> e.getActiveDecisions(3)
{0}
>>> obs.recordRetrace("bad") # transition doesn't exist
Traceback (most recent call last):
...
JournalParseError...
>>> obs.definiteDecisionTarget()
0
>>> obs.recordObserve('right', 'East', 'left')
>>> e.getSituation().graph.getTransitionRequirement('Start', 'right')
ReqNothing()
>>> obs.recordRequirement('crawl|small')
>>> e.getSituation().graph.getTransitionRequirement('Start', 'right')
ReqAny([ReqCapability('crawl'), ReqCapability('small')])
>>> obs.definiteDecisionTarget()
0
>>> obs.currentTransitionTarget()
(0, 'right')
>>> obs.currentReciprocalTarget()
(3, 'left')
>>> g = e.getSituation().graph
>>> print(g.namesListing(g).rstrip('\n'))
  0 (Start)
  1 (_u.0)
  2 (West)
  3 (East)
>>> # The use of relative mode to add remote observations
>>> obs.relative('East')
>>> obs.definiteDecisionTarget()
3
>>> obs.recordObserve('top_vent')
>>> obs.recordRequirement('crawl')
>>> obs.recordReciprocalRequirement('crawl')
>>> obs.recordMechanism('East', 'door', 'closed')  # door starts closed
>>> obs.recordAction('lever')
>>> obs.recordTransitionConsequence(
...     [base.effect(set=("door", "open")), base.effect(deactivate=True)]
... )  # lever opens the door
>>> obs.recordExplore('right_door', 'Outside', 'left_door')
>>> obs.definiteDecisionTarget()
5
>>> obs.recordRequirement('door:open')
>>> obs.recordReciprocalRequirement('door:open')
>>> obs.definiteDecisionTarget()
5
>>> obs.exploration.getExplorationStatus('East')
'noticed'
>>> obs.exploration.hasBeenVisited('East')
False
>>> obs.exploration.getExplorationStatus('Outside')
'noticed'
>>> obs.exploration.hasBeenVisited('Outside')
False
>>> obs.relative() # leave relative mode
>>> len(e) # starting states + two steps, no steps happen in relative mode
4
>>> obs.definiteDecisionTarget()  # out of relative mode; at Start
0
>>> g = e.getSituation().graph
>>> g.getTransitionRequirement(
...     g.getDestination('East', 'top_vent'),
...     'return'
... )
ReqCapability('crawl')
>>> g.getTransitionRequirement('East', 'top_vent')
ReqCapability('crawl')
>>> g.getTransitionRequirement('East', 'right_door')
ReqMechanism('door', 'open')
>>> g.getTransitionRequirement('Outside', 'left_door')
ReqMechanism('door', 'open')
>>> print(g.namesListing(g).rstrip('\n'))
  0 (Start)
  1 (_u.0)
  2 (West)
  3 (East)
  4 (_u.3)
  5 (Outside)
>>> # Now we demonstrate the use of "observe"
>>> e.getActiveDecisions()
{0}
>>> g.destinationsFrom(0)
{'bottom': 1, 'left': 2, 'right': 3}
>>> g.getDecision('Attic') is None
True
>>> obs.definiteDecisionTarget()
0
>>> obs.observe("o up Attic down\nx up\n   n at: Attic\no vent\nq crawl")
>>> g = e.getSituation().graph
>>> print(g.namesListing(g).rstrip('\n'))
  0 (Start)
  1 (_u.0)
  2 (West)
  3 (East)
  4 (_u.3)
  5 (Outside)
  6 (Attic)
  7 (_u.6)
>>> g.destinationsFrom(0)
{'bottom': 1, 'left': 2, 'right': 3, 'up': 6}
>>> g.nameFor(list(e.getActiveDecisions())[0])
'Attic'
>>> g.getTransitionRequirement('Attic', 'vent')
ReqCapability('crawl')
>>> sorted(list(g.destinationsFrom('Attic').items()))
[('down', 0), ('vent', 7)]
>>> obs.definiteDecisionTarget()  # in the Attic
6
>>> obs.observe("a getCrawl\n  At gain crawl\nx vent East top_vent")  # connecting to a previously-observed transition
>>> g = e.getSituation().graph
>>> print(g.namesListing(g).rstrip('\n'))
  0 (Start)
  1 (_u.0)
  2 (West)
  3 (East)
  5 (Outside)
  6 (Attic)
>>> g.getTransitionRequirement('East', 'top_vent')
ReqCapability('crawl')
>>> g.nameFor(g.getDestination('Attic', 'vent'))
'East'
>>> g.nameFor(g.getDestination('East', 'top_vent'))
'Attic'
>>> len(e) # exploration, action, and return are each 1
7
>>> e.getActiveDecisions(3)
{0}
>>> e.movementAtStep(3)
(0, 'up', 6)
>>> e.getActiveDecisions(4)
{6}
>>> g.nameFor(list(e.getActiveDecisions(4))[0])
'Attic'
>>> e.movementAtStep(4)
(6, 'getCrawl', 6)
>>> g.nameFor(list(e.getActiveDecisions(5))[0])
'Attic'
>>> e.movementAtStep(5)
(6, 'vent', 3)
>>> g.nameFor(list(e.getActiveDecisions(6))[0])
'East'
>>> # Now let's pull the lever and go outside, but first, we'll
>>> # return to the Start to demonstrate recordRetrace
>>> # note that recordReturn only applies when the destination of the
>>> # transition is not already known.
>>> obs.recordRetrace('left')  # back to Start
>>> obs.definiteDecisionTarget()
0
>>> obs.recordRetrace('right')  # and back to East
>>> obs.definiteDecisionTarget()
3
>>> obs.exploration.mechanismState('door')
'closed'
>>> obs.recordRetrace('lever', isAction=True)  # door is now open
>>> obs.exploration.mechanismState('door')
'open'
>>> obs.exploration.getExplorationStatus('Outside')
'noticed'
>>> obs.recordExplore('right_door')
>>> obs.definiteDecisionTarget()  # now we're Outside
5
>>> obs.recordReturn('tunnelUnder', 'Start', 'bottom')
>>> obs.definiteDecisionTarget()  # back at the start
0
>>> g = e.getSituation().graph
>>> print(g.namesListing(g).rstrip('\n'))
  0 (Start)
  2 (West)
  3 (East)
  5 (Outside)
  6 (Attic)
>>> g.destinationsFrom(0)
{'left': 2, 'right': 3, 'up': 6, 'bottom': 5}
>>> g.destinationsFrom(5)
{'left_door': 3, 'tunnelUnder': 0}

An example of the use of recordUnify and recordObviate.

>>> obs = JournalObserver()
>>> obs.observe('''
... S start
... x right hall left
... x right room left
... x vent vents right_vent
... ''')
>>> obs.recordObviate('middle_vent', 'hall', 'vent')
>>> obs.recordExplore('left_vent', 'new_room', 'vent')
>>> obs.recordUnify('start')
>>> e = obs.getExploration()
>>> len(e)
6
>>> e.getActiveDecisions(0)
set()
>>> [
...     e.getSituation(n).graph.nameFor(list(e.getActiveDecisions(n))[0])
...     for n in range(1, 6)
... ]
['start', 'hall', 'room', 'vents', 'start']
>>> g = e.getSituation().graph
>>> g.getDestination('start', 'vent')
3
>>> g.getDestination('vents', 'left_vent')
0
>>> g.getReciprocal('start', 'vent')
'left_vent'
>>> g.getReciprocal('vents', 'left_vent')
'vent'
>>> 'new_room' in g
False
JournalObserver(parseFormat: Optional[JournalParseFormat] = None)
1776    def __init__(self, parseFormat: Optional[JournalParseFormat] = None):
1777        """
1778        Sets up the observer. If a parse format is supplied, that will
1779        be used instead of the default parse format, which is just the
1780        result of creating a `ParseFormat` with default arguments.
1781
1782        A simple example:
1783
1784        >>> o = JournalObserver()
1785        >>> o.recordStart('hi')
1786        >>> o.exploration.getExplorationStatus('hi')
1787        'exploring'
1788        >>> e = o.getExploration()
1789        >>> len(e)
1790        2
1791        >>> g = e.getSituation().graph
1792        >>> len(g)
1793        1
1794        >>> e.getActiveContext()
1795        {\
1796'capabilities': {'capabilities': set(), 'tokens': {}, 'skills': {}},\
1797 'focalization': {'main': 'singular'},\
1798 'activeDomains': {'main'},\
1799 'activeDecisions': {'main': 0}\
1800}
1801        >>> list(g.nodes)[0]
1802        0
1803        >>> o.recordObserve('option')
1804        >>> list(g.nodes)
1805        [0, 1]
1806        >>> [g.nameFor(d) for d in g.nodes]
1807        ['hi', '_u.0']
1808        >>> o.recordZone(0, 'Lower')
1809        >>> [g.nameFor(d) for d in g.nodes]
1810        ['hi', '_u.0']
1811        >>> e.getActiveDecisions()
1812        {0}
1813        >>> o.recordZone(1, 'Upper')
1814        >>> o.recordExplore('option', 'bye', 'back')
1815        >>> g = e.getSituation().graph
1816        >>> [g.nameFor(d) for d in g.nodes]
1817        ['hi', 'bye']
1818        >>> o.recordObserve('option2')
1819        >>> import pytest
1820        >>> oldWarn = core.WARN_OF_NAME_COLLISIONS
1821        >>> core.WARN_OF_NAME_COLLISIONS = True
1822        >>> try:
1823        ...     with pytest.warns(core.DecisionCollisionWarning):
1824        ...         o.recordExplore('option2', 'Lower2::hi', 'back')
1825        ... finally:
1826        ...     core.WARN_OF_NAME_COLLISIONS = oldWarn
1827        >>> g = e.getSituation().graph
1828        >>> [g.nameFor(d) for d in g.nodes]
1829        ['hi', 'bye', 'hi']
1830        >>> # Prefix must be specified because it's ambiguous
1831        >>> o.recordWarp('Lower::hi')
1832        >>> g = e.getSituation().graph
1833        >>> [(d, g.nameFor(d)) for d in g.nodes]
1834        [(0, 'hi'), (1, 'bye'), (2, 'hi')]
1835        >>> e.getActiveDecisions()
1836        {0}
1837        >>> o.recordWarp('bye')
1838        >>> g = e.getSituation().graph
1839        >>> [(d, g.nameFor(d)) for d in g.nodes]
1840        [(0, 'hi'), (1, 'bye'), (2, 'hi')]
1841        >>> e.getActiveDecisions()
1842        {1}
1843        """
1844        if parseFormat is None:
1845            self.parseFormat = JournalParseFormat()
1846        else:
1847            self.parseFormat = parseFormat
1848
1849        self.uniqueNumber = 0
1850        self.aliases = {}
1851
1852        # Set up default observation preferences
1853        self.preferences = observationPreferences()
1854
1855        # Create a blank exploration
1856        self.exploration = core.DiscreteExploration()
1857
1858        # Debugging support
1859        self.prevSteps: Optional[int] = None
1860        self.prevDecisions: Optional[int] = None
1861
1862        # Current context tracking focal context, domain, focus point,
1863        # decision, and/or transition that's currently most relevant:
1864        self.context = observationContext()
1865
1866        # TODO: Stack of contexts?
1867        # Stored observation context can be restored as the current
1868        # state later. This is used to support relative mode.
1869        self.storedContext: Optional[
1870            ObservationContext
1871        ] = None
1872
1873        # Whether or not we're in relative mode.
1874        self.inRelativeMode = False
1875
1876        # Tracking which decisions we shouldn't auto-finalize
1877        self.dontFinalize: Set[base.DecisionID] = set()
1878
1879        # Tracking current parse location for errors & warnings
1880        self.journalTexts: List[str] = []  # a stack 'cause of macros
1881        self.parseIndices: List[int] = []  # also a stack

Sets up the observer. If a parse format is supplied, that will be used instead of the default parse format, which is just the result of creating a ParseFormat with default arguments.

A simple example:

>>> o = JournalObserver()
>>> o.recordStart('hi')
>>> o.exploration.getExplorationStatus('hi')
'exploring'
>>> e = o.getExploration()
>>> len(e)
2
>>> g = e.getSituation().graph
>>> len(g)
1
>>> e.getActiveContext()
{'capabilities': {'capabilities': set(), 'tokens': {}, 'skills': {}}, 'focalization': {'main': 'singular'}, 'activeDomains': {'main'}, 'activeDecisions': {'main': 0}}
>>> list(g.nodes)[0]
0
>>> o.recordObserve('option')
>>> list(g.nodes)
[0, 1]
>>> [g.nameFor(d) for d in g.nodes]
['hi', '_u.0']
>>> o.recordZone(0, 'Lower')
>>> [g.nameFor(d) for d in g.nodes]
['hi', '_u.0']
>>> e.getActiveDecisions()
{0}
>>> o.recordZone(1, 'Upper')
>>> o.recordExplore('option', 'bye', 'back')
>>> g = e.getSituation().graph
>>> [g.nameFor(d) for d in g.nodes]
['hi', 'bye']
>>> o.recordObserve('option2')
>>> import pytest
>>> oldWarn = core.WARN_OF_NAME_COLLISIONS
>>> core.WARN_OF_NAME_COLLISIONS = True
>>> try:
...     with pytest.warns(core.DecisionCollisionWarning):
...         o.recordExplore('option2', 'Lower2::hi', 'back')
... finally:
...     core.WARN_OF_NAME_COLLISIONS = oldWarn
>>> g = e.getSituation().graph
>>> [g.nameFor(d) for d in g.nodes]
['hi', 'bye', 'hi']
>>> # Prefix must be specified because it's ambiguous
>>> o.recordWarp('Lower::hi')
>>> g = e.getSituation().graph
>>> [(d, g.nameFor(d)) for d in g.nodes]
[(0, 'hi'), (1, 'bye'), (2, 'hi')]
>>> e.getActiveDecisions()
{0}
>>> o.recordWarp('bye')
>>> g = e.getSituation().graph
>>> [(d, g.nameFor(d)) for d in g.nodes]
[(0, 'hi'), (1, 'bye'), (2, 'hi')]
>>> e.getActiveDecisions()
{1}
parseFormat: JournalParseFormat

The parse format used to parse entries supplied as text. This also ends up controlling some of the decision and transition naming conventions that are followed, so it is not safe to change it mid-journal; it should be set once before observation begins, and may be accessed but should not be changed.

This is the exploration object being built via journal observations. Note that the exploration object may be empty (i.e., have length 0) even after the first few entries have been recorded because in some cases entries are ambiguous and are not translated into exploration steps until a further entry resolves that ambiguity.

preferences: ObservationPreferences

Preferences for the observation mechanisms. See ObservationPreferences.

uniqueNumber: int

A unique number to be substituted (prefixed with '_') into underscore-substitutions within aliases. Will be incremented for each such substitution.

aliases: Dict[str, Tuple[List[str], str]]

The defined aliases for this observer. Each alias has a name, and stored under that name is a list of parameters followed by a commands string.

prevSteps: Optional[int]
prevDecisions: Optional[int]
context
storedContext: Optional[ObservationContext]
inRelativeMode
dontFinalize: Set[int]
journalTexts: List[str]
parseIndices: List[int]
def getExploration(self) -> exploration.core.DiscreteExploration:
1883    def getExploration(self) -> core.DiscreteExploration:
1884        """
1885        Returns the exploration that this observer edits.
1886        """
1887        return self.exploration

Returns the exploration that this observer edits.

def nextUniqueName(self) -> str:
1889    def nextUniqueName(self) -> str:
1890        """
1891        Returns the next unique name for this observer, which is just an
1892        underscore followed by an integer. This increments
1893        `uniqueNumber`.
1894        """
1895        result = '_' + str(self.uniqueNumber)
1896        self.uniqueNumber += 1
1897        return result

Returns the next unique name for this observer, which is just an underscore followed by an integer. This increments uniqueNumber.

def currentDecisionTarget(self) -> Optional[int]:
1899    def currentDecisionTarget(self) -> Optional[base.DecisionID]:
1900        """
1901        Returns the decision which decision-based changes should be
1902        applied to. Changes depending on whether relative mode is
1903        active. Will be `None` when there is no current position (e.g.,
1904        before the exploration is started).
1905        """
1906        return self.context['decision']

Returns the decision which decision-based changes should be applied to. Changes depending on whether relative mode is active. Will be None when there is no current position (e.g., before the exploration is started).

def definiteDecisionTarget(self) -> int:
1908    def definiteDecisionTarget(self) -> base.DecisionID:
1909        """
1910        Works like `currentDecisionTarget` but raises a
1911        `core.MissingDecisionError` instead of returning `None` if there
1912        is no current decision.
1913        """
1914        result = self.currentDecisionTarget()
1915
1916        if result is None:
1917            raise core.MissingDecisionError("There is no current decision.")
1918        else:
1919            return result

Works like currentDecisionTarget but raises a core.MissingDecisionError instead of returning None if there is no current decision.

def decisionTargetSpecifier(self) -> exploration.base.DecisionSpecifier:
1921    def decisionTargetSpecifier(self) -> base.DecisionSpecifier:
1922        """
1923        Returns a `base.DecisionSpecifier` which includes domain, zone,
1924        and name for the current decision. The zone used is the first
1925        alphabetical lowest-level zone that the decision is in, which
1926        *could* in some cases remain ambiguous. If you're worried about
1927        that, use `definiteDecisionTarget` instead.
1928
1929        Like `definiteDecisionTarget` this will crash if there isn't a
1930        current decision target.
1931        """
1932        graph = self.exploration.getSituation().graph
1933        dID = self.definiteDecisionTarget()
1934        domain = graph.domainFor(dID)
1935        name = graph.nameFor(dID)
1936        inZones = graph.zoneAncestors(dID)
1937        # Alphabetical order (we have no better option)
1938        ordered = sorted(
1939            inZones,
1940            key=lambda z: (
1941                graph.zoneHierarchyLevel(z),   # level-0 first
1942                z  # alphabetical as tie-breaker
1943            )
1944        )
1945        if len(ordered) > 0:
1946            useZone = ordered[0]
1947        else:
1948            useZone = None
1949
1950        return base.DecisionSpecifier(
1951            domain=domain,
1952            zone=useZone,
1953            name=name
1954        )

Returns a base.DecisionSpecifier which includes domain, zone, and name for the current decision. The zone used is the first alphabetical lowest-level zone that the decision is in, which could in some cases remain ambiguous. If you're worried about that, use definiteDecisionTarget instead.

Like definiteDecisionTarget this will crash if there isn't a current decision target.

def currentTransitionTarget(self) -> Optional[Tuple[int, str]]:
1956    def currentTransitionTarget(
1957        self
1958    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
1959        """
1960        Returns the decision, transition pair that identifies the current
1961        transition which transition-based changes should apply to. Will
1962        be `None` when there is no current transition (e.g., just after a
1963        warp).
1964        """
1965        transition = self.context['transition']
1966        if transition is None:
1967            return None
1968        else:
1969            return transition

Returns the decision, transition pair that identifies the current transition which transition-based changes should apply to. Will be None when there is no current transition (e.g., just after a warp).

def currentReciprocalTarget(self) -> Optional[Tuple[int, str]]:
1971    def currentReciprocalTarget(
1972        self
1973    ) -> Optional[Tuple[base.DecisionID, base.Transition]]:
1974        """
1975        Returns the decision, transition pair that identifies the
1976        reciprocal of the `currentTransitionTarget`. Will be `None` when
1977        there is no current transition, or when the current transition
1978        doesn't have a reciprocal (e.g., after an ending).
1979        """
1980        # relative mode is handled by `currentTransitionTarget`
1981        target = self.currentTransitionTarget()
1982        if target is None:
1983            return None
1984        return self.exploration.getSituation().graph.getReciprocalPair(
1985            *target
1986        )

Returns the decision, transition pair that identifies the reciprocal of the currentTransitionTarget. Will be None when there is no current transition, or when the current transition doesn't have a reciprocal (e.g., after an ending).

def checkFormat( self, entryType: str, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'], target: Union[NoneType, Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], Tuple[Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], int]], pieces: List[str], expectedTargets: Union[NoneType, Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart'], Collection[Optional[Literal['decisionPart', 'transitionPart', 'reciprocalPart', 'bothPart', 'zonePart', 'actionPart', 'endingPart', 'unfinishedPart']]]], expectedPieces: Union[NoneType, int, Collection[int]]) -> None:
1988    def checkFormat(
1989        self,
1990        entryType: str,
1991        decisionType: base.DecisionType,
1992        target: Union[None, JournalTargetType, Tuple[JournalTargetType, int]],
1993        pieces: List[str],
1994        expectedTargets: Union[
1995            None,
1996            JournalTargetType,
1997            Collection[
1998                Union[None, JournalTargetType]
1999            ]
2000        ],
2001        expectedPieces: Union[None, int, Collection[int]]
2002    ) -> None:
2003        """
2004        Does format checking for a journal entry after
2005        `determineEntryType` is called. Checks that:
2006
2007        - A decision type other than 'active' is only used for entries
2008            where that makes sense.
2009        - The target is one from an allowed list of targets (or is `None`
2010            if `expectedTargets` is set to `None`)
2011        - The number of pieces of content is a specific number or within
2012            a specific collection of allowed numbers. If `expectedPieces`
2013            is set to None, there is no restriction on the number of
2014            pieces.
2015
2016        Raises a `JournalParseError` if its expectations are violated.
2017        """
2018        if decisionType != 'active' and entryType not in (
2019            'START',
2020            'explore',
2021            'retrace',
2022            'return',
2023            'action',
2024            'warp',
2025            'wait',
2026            'END',
2027            'revert'
2028        ):
2029            raise JournalParseError(
2030                f"{entryType} entry may not specify a non-standard"
2031                f" decision type (got {decisionType!r}), because it is"
2032                f" not associated with an exploration action."
2033            )
2034
2035        if expectedTargets is None:
2036            if target is not None:
2037                raise JournalParseError(
2038                    f"{entryType} entry may not specify a target."
2039                )
2040        else:
2041            if isinstance(expectedTargets, str):
2042                expected = cast(
2043                    Collection[Union[None, JournalTargetType]],
2044                    [expectedTargets]
2045                )
2046            else:
2047                expected = cast(
2048                    Collection[Union[None, JournalTargetType]],
2049                    expectedTargets
2050                )
2051            tType = target
2052            if isinstance(tType, tuple):
2053                tType = tType[0]
2054
2055            if tType not in expected:
2056                raise JournalParseError(
2057                    f"{entryType} entry had invalid target {target!r}."
2058                    f" Expected one of:\n{expected}"
2059                )
2060
2061        if expectedPieces is None:
2062            # No restriction
2063            pass
2064        elif isinstance(expectedPieces, int):
2065            if len(pieces) != expectedPieces:
2066                raise JournalParseError(
2067                    f"{entryType} entry had {len(pieces)} arguments but"
2068                    f" only {expectedPieces} argument(s) is/are allowed."
2069                )
2070
2071        elif len(pieces) not in expectedPieces:
2072            allowed = ', '.join(str(x) for x in expectedPieces)
2073            raise JournalParseError(
2074                f"{entryType} entry had {len(pieces)} arguments but the"
2075                f" allowed argument counts are: {allowed}"
2076            )

Does format checking for a journal entry after determineEntryType is called. Checks that:

  • A decision type other than 'active' is only used for entries where that makes sense.
  • The target is one from an allowed list of targets (or is None if expectedTargets is set to None)
  • The number of pieces of content is a specific number or within a specific collection of allowed numbers. If expectedPieces is set to None, there is no restriction on the number of pieces.

Raises a JournalParseError if its expectations are violated.

def parseOneCommand(self, journalText: str, startIndex: int) -> Tuple[List[str], int]:
2078    def parseOneCommand(
2079        self,
2080        journalText: str,
2081        startIndex: int
2082    ) -> Tuple[List[str], int]:
2083        """
2084        Parses a single command from the given journal text, starting at
2085        the specified start index. Each command occupies a single line,
2086        except when blocks are present in which case it may stretch
2087        across multiple lines. This function splits the command up into a
2088        list of strings (including multi-line strings and/or strings
2089        with spaces in them when blocks are used). It returns that list
2090        of strings, along with the index after the newline at the end of
2091        the command it parsed (which could be used as the start index
2092        for the next command). If the command has no newline after it
2093        (only possible when the string ends) the returned index will be
2094        the length of the string.
2095
2096        If the line starting with the start character is empty (or just
2097        contains spaces), the result will be an empty list along with the
2098        index for the start of the next line.
2099
2100        Examples:
2101
2102        >>> o = JournalObserver()
2103        >>> commands = '''\\
2104        ... S start
2105        ... o option
2106        ...
2107        ... x option next back
2108        ... o lever
2109        ...   e edit [
2110        ...     o bridge
2111        ...       q speed
2112        ...   ] [
2113        ...     o bridge
2114        ...       q X
2115        ...   ]
2116        ... a lever
2117        ... '''
2118        >>> o.parseOneCommand(commands, 0)
2119        (['S', 'start'], 8)
2120        >>> o.parseOneCommand(commands, 8)
2121        (['o', 'option'], 17)
2122        >>> o.parseOneCommand(commands, 17)
2123        ([], 18)
2124        >>> o.parseOneCommand(commands, 18)
2125        (['x', 'option', 'next', 'back'], 37)
2126        >>> o.parseOneCommand(commands, 37)
2127        (['o', 'lever'], 45)
2128        >>> bits, end = o.parseOneCommand(commands, 45)
2129        >>> bits[:2]
2130        ['e', 'edit']
2131        >>> bits[2]
2132        'o bridge\\n      q speed'
2133        >>> bits[3]
2134        'o bridge\\n      q X'
2135        >>> len(bits)
2136        4
2137        >>> end
2138        116
2139        >>> o.parseOneCommand(commands, end)
2140        (['a', 'lever'], 124)
2141
2142        >>> o = JournalObserver()
2143        >>> s = "o up Attic down\\nx up\\no vent\\nq crawl"
2144        >>> o.parseOneCommand(s, 0)
2145        (['o', 'up', 'Attic', 'down'], 16)
2146        >>> o.parseOneCommand(s, 16)
2147        (['x', 'up'], 21)
2148        >>> o.parseOneCommand(s, 21)
2149        (['o', 'vent'], 28)
2150        >>> o.parseOneCommand(s, 28)
2151        (['q', 'crawl'], 35)
2152        """
2153
2154        index = startIndex
2155        unit: Optional[str] = None
2156        bits: List[str] = []
2157        pf = self.parseFormat  # shortcut variable
2158        while index < len(journalText):
2159            char = journalText[index]
2160            if char.isspace():
2161                # Space after non-spaces -> end of unit
2162                if unit is not None:
2163                    bits.append(unit)
2164                    unit = None
2165                # End of line -> end of command
2166                if char == '\n':
2167                    index += 1
2168                    break
2169            else:
2170                # Non-space -> check for block
2171                if char == pf.blockStart:
2172                    if unit is not None:
2173                        bits.append(unit)
2174                        unit = None
2175                    blockEnd = pf.findBlockEnd(journalText, index)
2176                    block = journalText[index + 1:blockEnd - 1].strip()
2177                    bits.append(block)
2178                    index = blockEnd  # +1 added below
2179                elif unit is None:  # Initial non-space -> start of unit
2180                    unit = char
2181                else:  # Continuing non-space -> accumulate
2182                    unit += char
2183            # Increment index
2184            index += 1
2185
2186        # Grab final unit if there is one hanging
2187        if unit is not None:
2188            bits.append(unit)
2189
2190        return (bits, index)

Parses a single command from the given journal text, starting at the specified start index. Each command occupies a single line, except when blocks are present in which case it may stretch across multiple lines. This function splits the command up into a list of strings (including multi-line strings and/or strings with spaces in them when blocks are used). It returns that list of strings, along with the index after the newline at the end of the command it parsed (which could be used as the start index for the next command). If the command has no newline after it (only possible when the string ends) the returned index will be the length of the string.

If the line starting with the start character is empty (or just contains spaces), the result will be an empty list along with the index for the start of the next line.

Examples:

>>> o = JournalObserver()
>>> commands = '''\
... S start
... o option
...
... x option next back
... o lever
...   e edit [
...     o bridge
...       q speed
...   ] [
...     o bridge
...       q X
...   ]
... a lever
... '''
>>> o.parseOneCommand(commands, 0)
(['S', 'start'], 8)
>>> o.parseOneCommand(commands, 8)
(['o', 'option'], 17)
>>> o.parseOneCommand(commands, 17)
([], 18)
>>> o.parseOneCommand(commands, 18)
(['x', 'option', 'next', 'back'], 37)
>>> o.parseOneCommand(commands, 37)
(['o', 'lever'], 45)
>>> bits, end = o.parseOneCommand(commands, 45)
>>> bits[:2]
['e', 'edit']
>>> bits[2]
'o bridge\n      q speed'
>>> bits[3]
'o bridge\n      q X'
>>> len(bits)
4
>>> end
116
>>> o.parseOneCommand(commands, end)
(['a', 'lever'], 124)
>>> o = JournalObserver()
>>> s = "o up Attic down\nx up\no vent\nq crawl"
>>> o.parseOneCommand(s, 0)
(['o', 'up', 'Attic', 'down'], 16)
>>> o.parseOneCommand(s, 16)
(['x', 'up'], 21)
>>> o.parseOneCommand(s, 21)
(['o', 'vent'], 28)
>>> o.parseOneCommand(s, 28)
(['q', 'crawl'], 35)
def warn(self, message: str) -> None:
2192    def warn(self, message: str) -> None:
2193        """
2194        Issues a `JournalParseWarning`.
2195        """
2196        if len(self.journalTexts) == 0 or len(self.parseIndices) == 0:
2197            warnings.warn(message, JournalParseWarning)
2198        else:
2199            # Note: We use the basal position info because that will
2200            # typically be much more useful when debugging
2201            ec = errorContext(self.journalTexts[0], self.parseIndices[0])
2202            errorCM = textwrap.indent(errorContextMessage(ec), '  ')
2203            warnings.warn(errorCM + '\n' + message, JournalParseWarning)
def observe(self, journalText: str) -> None:
2205    def observe(self, journalText: str) -> None:
2206        """
2207        Ingests one or more journal blocks in text format (as a
2208        multi-line string) and updates the exploration being built by
2209        this observer, as well as updating internal state.
2210
2211        This method can be called multiple times to process a longer
2212        journal incrementally including line-by-line.
2213
2214        The `journalText` and `parseIndex` fields will be updated during
2215        parsing to support contextual error messages and warnings.
2216
2217        ## Example:
2218
2219        >>> obs = JournalObserver()
2220        >>> oldWarn = core.WARN_OF_NAME_COLLISIONS
2221        >>> try:
2222        ...     obs.observe('''\\
2223        ... S Room1::start
2224        ... zz Region
2225        ... o nope
2226        ...   q power|tokens*3
2227        ... o unexplored
2228        ... o onwards
2229        ... x onwards sub_room backwards
2230        ... t backwards
2231        ... o down
2232        ...
2233        ... x down Room2::middle up
2234        ... a box
2235        ...   At deactivate
2236        ...   At gain tokens*1
2237        ... o left
2238        ... o right
2239        ...   gt blue
2240        ...
2241        ... x right Room3::middle left
2242        ... o right
2243        ... a miniboss
2244        ...   At deactivate
2245        ...   At gain power
2246        ... x right - left
2247        ... o ledge
2248        ...   q tall
2249        ... t left
2250        ... t left
2251        ... t up
2252        ...
2253        ... x nope secret back
2254        ... ''')
2255        ... finally:
2256        ...     core.WARN_OF_NAME_COLLISIONS = oldWarn
2257        >>> e = obs.getExploration()
2258        >>> len(e)
2259        13
2260        >>> g = e.getSituation().graph
2261        >>> len(g)
2262        9
2263        >>> def showDestinations(g, r):
2264        ...     if isinstance(r, str):
2265        ...         r = obs.parseFormat.parseDecisionSpecifier(r)
2266        ...     d = g.destinationsFrom(r)
2267        ...     for outgoing in sorted(d):
2268        ...         req = g.getTransitionRequirement(r, outgoing)
2269        ...         if req is None or req == base.ReqNothing():
2270        ...             req = ''
2271        ...         else:
2272        ...             req = ' ' + repr(req)
2273        ...         print(outgoing, g.identityOf(d[outgoing]) + req)
2274        ...
2275        >>> "start" in g
2276        False
2277        >>> showDestinations(g, "Room1::start")
2278        down 4 (Room2::middle)
2279        nope 1 (Room1::secret) ReqAny([ReqCapability('power'),\
2280 ReqTokens('tokens', 3)])
2281        onwards 3 (Room1::sub_room)
2282        unexplored 2 (_u.1)
2283        >>> showDestinations(g, "Room1::secret")
2284        back 0 (Room1::start)
2285        >>> showDestinations(g, "Room1::sub_room")
2286        backwards 0 (Room1::start)
2287        >>> showDestinations(g, "Room2::middle")
2288        box 4 (Room2::middle)
2289        left 5 (_u.4)
2290        right 6 (Room3::middle)
2291        up 0 (Room1::start)
2292        >>> g.transitionTags(4, "right")
2293        {'blue': 1}
2294        >>> showDestinations(g, "Room3::middle")
2295        left 4 (Room2::middle)
2296        miniboss 6 (Room3::middle)
2297        right 7 (Room3::-)
2298        >>> showDestinations(g, "Room3::-")
2299        ledge 8 (_u.7) ReqCapability('tall')
2300        left 6 (Room3::middle)
2301        >>> showDestinations(g, "_u.7")
2302        return 7 (Room3::-)
2303        >>> e.getActiveDecisions()
2304        {1}
2305        >>> g.identityOf(1)
2306        '1 (Room1::secret)'
2307
2308        Note that there are plenty of other annotations not shown in
2309        this example; see `DEFAULT_FORMAT` for the default mapping from
2310        journal entry types to markers, and see `JournalEntryType` for
2311        the explanation for each entry type.
2312
2313        Most entries start with a marker (which includes one character
2314        for the type and possibly one for the target) followed by a
2315        single space, and everything after that is the content of the
2316        entry.
2317        """
2318        # Normalize newlines
2319        journalText = journalText\
2320            .replace('\r\n', '\n')\
2321            .replace('\n\r', '\n')\
2322            .replace('\r', '\n')
2323
2324        # Shortcut variable
2325        pf = self.parseFormat
2326
2327        # Remove comments from entire text
2328        journalText = pf.removeComments(journalText)
2329
2330        # TODO: Give access to comments in error messages?
2331        # Store for error messages
2332        self.journalTexts.append(journalText)
2333        self.parseIndices.append(0)
2334
2335        startAt = 0
2336        try:
2337            while startAt < len(journalText):
2338                self.parseIndices[-1] = startAt
2339                bits, startAt = self.parseOneCommand(journalText, startAt)
2340
2341                if len(bits) == 0:
2342                    continue
2343
2344                eType, dType, eTarget, eParts = pf.determineEntryType(bits)
2345                if eType == 'preference':
2346                    self.checkFormat(
2347                        'preference',
2348                        dType,
2349                        eTarget,
2350                        eParts,
2351                        None,
2352                        2
2353                    )
2354                    pref = eParts[0]
2355                    opAnn = get_type_hints(ObservationPreferences)
2356                    if pref not in opAnn:
2357                        raise JournalParseError(
2358                            f"Invalid preference name {pref!r}."
2359                        )
2360
2361                    prefVal: Union[None, str, bool, Set[str]]
2362                    if opAnn[pref] is bool:
2363                        prefVal = pf.onOff(eParts[1])
2364                        if prefVal is None:
2365                            self.warn(
2366                                f"On/off value {eParts[1]!r} is neither"
2367                                f" {pf.markerFor('on')!r} nor"
2368                                f" {pf.markerFor('off')!r}. Assuming"
2369                                f" 'off'."
2370                            )
2371                    elif opAnn[pref] == Set[str]:
2372                        prefVal = set(' '.join(eParts[1:]).split())
2373                    else:  # we assume it's a string
2374                        assert opAnn[pref] is str
2375                        prefVal = eParts[1]
2376
2377                    # Set the preference value (type checked above)
2378                    self.preferences[pref] = prefVal  # type: ignore [literal-required] # noqa: E501
2379
2380                elif eType == 'alias':
2381                    self.checkFormat(
2382                        "alias",
2383                        dType,
2384                        eTarget,
2385                        eParts,
2386                        None,
2387                        None
2388                    )
2389
2390                    if len(eParts) < 2:
2391                        raise JournalParseError(
2392                            "Alias entry must include at least an alias"
2393                            " name and a commands list."
2394                        )
2395                    aliasName = eParts[0]
2396                    parameters = eParts[1:-1]
2397                    commands = eParts[-1]
2398                    self.defineAlias(aliasName, parameters, commands)
2399
2400                elif eType == 'custom':
2401                    self.checkFormat(
2402                        "custom",
2403                        dType,
2404                        eTarget,
2405                        eParts,
2406                        None,
2407                        None
2408                    )
2409                    if len(eParts) == 0:
2410                        raise JournalParseError(
2411                            "Custom entry must include at least an alias"
2412                            " name."
2413                        )
2414                    self.deployAlias(eParts[0], eParts[1:])
2415
2416                elif eType == 'DEBUG':
2417                    self.checkFormat(
2418                        "DEBUG",
2419                        dType,
2420                        eTarget,
2421                        eParts,
2422                        None,
2423                        {1, 2}
2424                    )
2425                    if eParts[0] not in get_args(DebugAction):
2426                        raise JournalParseError(
2427                            f"Invalid debug action: {eParts[0]!r}"
2428                        )
2429                    dAction = cast(DebugAction, eParts[0])
2430                    if len(eParts) > 1:
2431                        self.doDebug(dAction, eParts[1])
2432                    else:
2433                        self.doDebug(dAction)
2434
2435                elif eType == 'START':
2436                    self.checkFormat(
2437                        "START",
2438                        dType,
2439                        eTarget,
2440                        eParts,
2441                        None,
2442                        1
2443                    )
2444
2445                    where = pf.parseDecisionSpecifier(eParts[0])
2446                    if isinstance(where, base.DecisionID):
2447                        raise JournalParseError(
2448                            f"Can't use {repr(where)} as a start"
2449                            f" because the start must be a decision"
2450                            f" name, not a decision ID."
2451                        )
2452                    self.recordStart(where, dType)
2453
2454                elif eType == 'explore':
2455                    self.checkFormat(
2456                        "explore",
2457                        dType,
2458                        eTarget,
2459                        eParts,
2460                        None,
2461                        {1, 2, 3}
2462                    )
2463
2464                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2465
2466                    if len(eParts) == 1:
2467                        self.recordExplore(tr, decisionType=dType)
2468                    elif len(eParts) == 2:
2469                        destination = pf.parseDecisionSpecifier(eParts[1])
2470                        self.recordExplore(
2471                            tr,
2472                            destination,
2473                            decisionType=dType
2474                        )
2475                    else:
2476                        destination = pf.parseDecisionSpecifier(eParts[1])
2477                        self.recordExplore(
2478                            tr,
2479                            destination,
2480                            eParts[2],
2481                            decisionType=dType
2482                        )
2483
2484                elif eType == 'return':
2485                    self.checkFormat(
2486                        "return",
2487                        dType,
2488                        eTarget,
2489                        eParts,
2490                        None,
2491                        {1, 2, 3}
2492                    )
2493                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2494                    if len(eParts) > 1:
2495                        destination = pf.parseDecisionSpecifier(eParts[1])
2496                    else:
2497                        destination = None
2498                    if len(eParts) > 2:
2499                        reciprocal = eParts[2]
2500                    else:
2501                        reciprocal = None
2502                    self.recordReturn(
2503                        tr,
2504                        destination,
2505                        reciprocal,
2506                        decisionType=dType
2507                    )
2508
2509                elif eType == 'action':
2510                    self.checkFormat(
2511                        "action",
2512                        dType,
2513                        eTarget,
2514                        eParts,
2515                        None,
2516                        1
2517                    )
2518                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2519                    self.recordAction(tr, decisionType=dType)
2520
2521                elif eType == 'retrace':
2522                    self.checkFormat(
2523                        "retrace",
2524                        dType,
2525                        eTarget,
2526                        eParts,
2527                        (None, 'actionPart'),
2528                        1
2529                    )
2530                    tr = pf.parseTransitionWithOutcomes(eParts[0])
2531                    self.recordRetrace(
2532                        tr,
2533                        decisionType=dType,
2534                        isAction=eTarget == 'actionPart'
2535                    )
2536
2537                elif eType == 'warp':
2538                    self.checkFormat(
2539                        "warp",
2540                        dType,
2541                        eTarget,
2542                        eParts,
2543                        None,
2544                        {1}
2545                    )
2546
2547                    destination = pf.parseDecisionSpecifier(eParts[0])
2548                    self.recordWarp(destination, decisionType=dType)
2549
2550                elif eType == 'wait':
2551                    self.checkFormat(
2552                        "wait",
2553                        dType,
2554                        eTarget,
2555                        eParts,
2556                        None,
2557                        0
2558                    )
2559                    self.recordWait(decisionType=dType)
2560
2561                elif eType == 'observe':
2562                    self.checkFormat(
2563                        "observe",
2564                        dType,
2565                        eTarget,
2566                        eParts,
2567                        (None, 'actionPart', 'endingPart'),
2568                        (1, 2, 3)
2569                    )
2570                    if eTarget is None:
2571                        self.recordObserve(*eParts)
2572                    elif eTarget == 'actionPart':
2573                        if len(eParts) > 1:
2574                            raise JournalParseError(
2575                                f"Observing action {eParts[0]!r} at"
2576                                f" {self.definiteDecisionTarget()!r}:"
2577                                f" neither a destination nor a"
2578                                f" reciprocal may be specified when"
2579                                f" observing an action (did you mean to"
2580                                f" observe a transition?)."
2581                            )
2582                        self.recordObserveAction(*eParts)
2583                    elif eTarget == 'endingPart':
2584                        if len(eParts) > 1:
2585                            raise JournalParseError(
2586                                f"Observing ending {eParts[0]!r} at"
2587                                f" {self.definiteDecisionTarget()!r}:"
2588                                f" neither a destination nor a"
2589                                f" reciprocal may be specified when"
2590                                f" observing an ending (did you mean to"
2591                                f" observe a transition?)."
2592                            )
2593                        self.recordObserveEnding(*eParts)
2594
2595                elif eType == 'END':
2596                    self.checkFormat(
2597                        "END",
2598                        dType,
2599                        eTarget,
2600                        eParts,
2601                        (None, 'actionPart'),
2602                        1
2603                    )
2604                    self.recordEnd(
2605                        eParts[0],
2606                        eTarget == 'actionPart',
2607                        decisionType=dType
2608                    )
2609
2610                elif eType == 'mechanism':
2611                    self.checkFormat(
2612                        "mechanism",
2613                        dType,
2614                        eTarget,
2615                        eParts,
2616                        None,
2617                        1
2618                    )
2619                    mReq = pf.parseRequirement(eParts[0])
2620                    if (
2621                        not isinstance(mReq, base.ReqMechanism)
2622                     or not isinstance(
2623                            mReq.mechanism,
2624                            (base.MechanismName, base.MechanismSpecifier)
2625                        )
2626                    ):
2627                        raise JournalParseError(
2628                            f"Invalid mechanism declaration"
2629                            f" {eParts[0]!r}. Declaration must specify"
2630                            f" mechanism name and starting state."
2631                        )
2632                    mState = mReq.reqState
2633                    if isinstance(mReq.mechanism, base.MechanismName):
2634                        where = self.definiteDecisionTarget()
2635                        mName = mReq.mechanism
2636                    else:
2637                        assert isinstance(
2638                            mReq.mechanism,
2639                            base.MechanismSpecifier
2640                        )
2641                        mSpec = mReq.mechanism
2642                        mName = mSpec.name
2643                        if mSpec.decision is not None:
2644                            where = base.DecisionSpecifier(
2645                                mSpec.domain,
2646                                mSpec.zone,
2647                                mSpec.decision
2648                            )
2649                        else:
2650                            where = self.definiteDecisionTarget()
2651                            graph = self.exploration.getSituation().graph
2652                            thisDomain = graph.domainFor(where)
2653                            theseZones = graph.zoneAncestors(where)
2654                            if (
2655                                mSpec.domain is not None
2656                            and mSpec.domain != thisDomain
2657                            ):
2658                                raise JournalParseError(
2659                                    f"Mechanism specifier {mSpec!r}"
2660                                    f" does not specify a decision but"
2661                                    f" includes domain {mSpec.domain!r}"
2662                                    f" which does not match the domain"
2663                                    f" {thisDomain!r} of the current"
2664                                    f" decision {graph.identityOf(where)}"
2665                                )
2666                            if (
2667                                mSpec.zone is not None
2668                            and mSpec.zone not in theseZones
2669                            ):
2670                                raise JournalParseError(
2671                                    f"Mechanism specifier {mSpec!r}"
2672                                    f" does not specify a decision but"
2673                                    f" includes zone {mSpec.zone!r}"
2674                                    f" which is not one of the zones"
2675                                    f" that the current decision"
2676                                    f" {graph.identityOf(where)} is in:"
2677                                    f"\n{theseZones!r}"
2678                                )
2679                    self.recordMechanism(where, mName, mState)
2680
2681                elif eType == 'requirement':
2682                    self.checkFormat(
2683                        "requirement",
2684                        dType,
2685                        eTarget,
2686                        eParts,
2687                        (None, 'reciprocalPart', 'bothPart'),
2688                        None
2689                    )
2690                    req = pf.parseRequirement(' '.join(eParts))
2691                    if eTarget in (None, 'bothPart'):
2692                        self.recordRequirement(req)
2693                    if eTarget in ('reciprocalPart', 'bothPart'):
2694                        self.recordReciprocalRequirement(req)
2695
2696                elif eType == 'effect':
2697                    self.checkFormat(
2698                        "effect",
2699                        dType,
2700                        eTarget,
2701                        eParts,
2702                        (None, 'reciprocalPart', 'bothPart'),
2703                        None
2704                    )
2705
2706                    consequence: base.Consequence
2707                    try:
2708                        consequence = pf.parseConsequence(' '.join(eParts))
2709                    except parsing.ParseError:
2710                        consequence = [pf.parseEffect(' '.join(eParts))]
2711
2712                    if eTarget in (None, 'bothPart'):
2713                        self.recordTransitionConsequence(consequence)
2714                    if eTarget in ('reciprocalPart', 'bothPart'):
2715                        self.recordReciprocalConsequence(consequence)
2716
2717                elif eType == 'apply':
2718                    self.checkFormat(
2719                        "apply",
2720                        dType,
2721                        eTarget,
2722                        eParts,
2723                        (None, 'transitionPart'),
2724                        None
2725                    )
2726
2727                    toApply: base.Consequence
2728                    try:
2729                        toApply = pf.parseConsequence(' '.join(eParts))
2730                    except parsing.ParseError:
2731                        toApply = [pf.parseEffect(' '.join(eParts))]
2732
2733                    # If we targeted a transition, that means we wanted
2734                    # to both apply the consequence now AND set it up as
2735                    # an consequence of the transition we just took.
2736                    if eTarget == 'transitionPart':
2737                        if self.context['transition'] is None:
2738                            raise JournalParseError(
2739                                "Can't apply a consequence to a"
2740                                " transition here because there is no"
2741                                " current relevant transition."
2742                            )
2743                        # We need to apply these consequences as part of
2744                        # the transition so their trigger count will be
2745                        # tracked properly, but we do not want to
2746                        # re-apply the other parts of the consequence.
2747                        self.recordAdditionalTransitionConsequence(
2748                            toApply
2749                        )
2750                    else:
2751                        # Otherwise just apply the consequence
2752                        self.exploration.applyExtraneousConsequence(
2753                            toApply,
2754                            where=self.context['transition'],
2755                            moveWhich=self.context['focus']
2756                        )
2757                        # Note: no situation-based variables need
2758                        # updating here
2759
2760                elif eType == 'tag':
2761                    self.checkFormat(
2762                        "tag",
2763                        dType,
2764                        eTarget,
2765                        eParts,
2766                        (
2767                            None,
2768                            'decisionPart',
2769                            'transitionPart',
2770                            'reciprocalPart',
2771                            'bothPart',
2772                            'zonePart'
2773                        ),
2774                        None
2775                    )
2776                    tag: base.Tag
2777                    value: base.TagValue
2778                    if len(eParts) == 0:
2779                        raise JournalParseError(
2780                            "tag entry must include at least a tag name."
2781                        )
2782                    elif len(eParts) == 1:
2783                        tag = eParts[0]
2784                        value = 1
2785                    elif len(eParts) == 2:
2786                        tag, value = eParts
2787                        value = pf.parseTagValue(value)
2788                    else:
2789                        raise JournalParseError(
2790                            f"tag entry has too many parts (only a tag"
2791                            f" name and a tag value are allowed). Got:"
2792                            f" {eParts}"
2793                        )
2794
2795                    if eTarget is None:
2796                        self.recordTagStep(tag, value)
2797                    elif eTarget == "decisionPart":
2798                        self.recordTagDecision(tag, value)
2799                    elif eTarget == "transitionPart":
2800                        self.recordTagTranstion(tag, value)
2801                    elif eTarget == "reciprocalPart":
2802                        self.recordTagReciprocal(tag, value)
2803                    elif eTarget == "bothPart":
2804                        self.recordTagTranstion(tag, value)
2805                        self.recordTagReciprocal(tag, value)
2806                    elif eTarget == "zonePart":
2807                        self.recordTagZone(0, tag, value)
2808                    elif (
2809                        isinstance(eTarget, tuple)
2810                    and len(eTarget) == 2
2811                    and eTarget[0] == "zonePart"
2812                    and isinstance(eTarget[1], int)
2813                    ):
2814                        self.recordTagZone(eTarget[1] - 1, tag, value)
2815                    else:
2816                        raise JournalParseError(
2817                            f"Invalid tag target type {eTarget!r}."
2818                        )
2819
2820                elif eType == 'annotate':
2821                    self.checkFormat(
2822                        "annotate",
2823                        dType,
2824                        eTarget,
2825                        eParts,
2826                        (
2827                            None,
2828                            'decisionPart',
2829                            'transitionPart',
2830                            'reciprocalPart',
2831                            'bothPart'
2832                        ),
2833                        None
2834                    )
2835                    if len(eParts) == 0:
2836                        raise JournalParseError(
2837                            "annotation may not be empty."
2838                        )
2839                    combined = ' '.join(eParts)
2840                    if eTarget is None:
2841                        self.recordAnnotateStep(combined)
2842                    elif eTarget == "decisionPart":
2843                        self.recordAnnotateDecision(combined)
2844                    elif eTarget == "transitionPart":
2845                        self.recordAnnotateTranstion(combined)
2846                    elif eTarget == "reciprocalPart":
2847                        self.recordAnnotateReciprocal(combined)
2848                    elif eTarget == "bothPart":
2849                        self.recordAnnotateTranstion(combined)
2850                        self.recordAnnotateReciprocal(combined)
2851                    elif eTarget == "zonePart":
2852                        self.recordAnnotateZone(0, combined)
2853                    elif (
2854                        isinstance(eTarget, tuple)
2855                    and len(eTarget) == 2
2856                    and eTarget[0] == "zonePart"
2857                    and isinstance(eTarget[1], int)
2858                    ):
2859                        self.recordAnnotateZone(eTarget[1] - 1, combined)
2860                    else:
2861                        raise JournalParseError(
2862                            f"Invalid annotation target type {eTarget!r}."
2863                        )
2864
2865                elif eType == 'context':
2866                    self.checkFormat(
2867                        "context",
2868                        dType,
2869                        eTarget,
2870                        eParts,
2871                        None,
2872                        1
2873                    )
2874                    if eParts[0] == pf.markerFor('commonContext'):
2875                        self.recordContextSwap(None)
2876                    else:
2877                        self.recordContextSwap(eParts[0])
2878
2879                elif eType == 'domain':
2880                    self.checkFormat(
2881                        "domain",
2882                        dType,
2883                        eTarget,
2884                        eParts,
2885                        None,
2886                        {1, 2, 3}
2887                    )
2888                    inCommon = False
2889                    if eParts[-1] == pf.markerFor('commonContext'):
2890                        eParts = eParts[:-1]
2891                        inCommon = True
2892                    if len(eParts) == 3:
2893                        raise JournalParseError(
2894                            f"A domain entry may only have 1 or 2"
2895                            f" arguments unless the last argument is"
2896                            f" {repr(pf.markerFor('commonContext'))}"
2897                        )
2898                    elif len(eParts) == 2:
2899                        if eParts[0] == pf.markerFor('exclusiveDomain'):
2900                            self.recordDomainFocus(
2901                                eParts[1],
2902                                exclusive=True,
2903                                inCommon=inCommon
2904                            )
2905                        elif eParts[0] == pf.markerFor('notApplicable'):
2906                            # Deactivate the domain
2907                            self.recordDomainUnfocus(
2908                                eParts[1],
2909                                inCommon=inCommon
2910                            )
2911                        else:
2912                            # Set up new domain w/ given focalization
2913                            focalization = pf.parseFocalization(eParts[1])
2914                            self.recordNewDomain(
2915                                eParts[0],
2916                                focalization,
2917                                inCommon=inCommon
2918                            )
2919                    else:
2920                        # Focus the domain (or possibly create it)
2921                        self.recordDomainFocus(
2922                            eParts[0],
2923                            inCommon=inCommon
2924                        )
2925
2926                elif eType == 'focus':
2927                    self.checkFormat(
2928                        "focus",
2929                        dType,
2930                        eTarget,
2931                        eParts,
2932                        None,
2933                        {1, 2}
2934                    )
2935                    if len(eParts) == 2:  # explicit domain
2936                        self.recordFocusOn(eParts[1], eParts[0])
2937                    else:  # implicit domain
2938                        self.recordFocusOn(eParts[0])
2939
2940                elif eType == 'zone':
2941                    self.checkFormat(
2942                        "zone",
2943                        dType,
2944                        eTarget,
2945                        eParts,
2946                        (None, 'zonePart'),
2947                        1
2948                    )
2949                    if eTarget is None:
2950                        level = 0
2951                    elif eTarget == 'zonePart':
2952                        level = 1
2953                    else:
2954                        assert isinstance(eTarget, tuple)
2955                        assert len(eTarget) == 2
2956                        level = eTarget[1]
2957                    self.recordZone(level, eParts[0])
2958
2959                elif eType == 'unify':
2960                    self.checkFormat(
2961                        "unify",
2962                        dType,
2963                        eTarget,
2964                        eParts,
2965                        (None, 'transitionPart', 'reciprocalPart'),
2966                        (1, 2)
2967                    )
2968                    if eTarget is None:
2969                        decisions = [
2970                            pf.parseDecisionSpecifier(p)
2971                            for p in eParts
2972                        ]
2973                        self.recordUnify(*decisions)
2974                    elif eTarget == 'transitionPart':
2975                        if len(eParts) != 1:
2976                            raise JournalParseError(
2977                                "A transition unification entry may only"
2978                                f" have one argument, but we got"
2979                                f" {len(eParts)}."
2980                            )
2981                        self.recordUnifyTransition(eParts[0])
2982                    elif eTarget == 'reciprocalPart':
2983                        if len(eParts) != 1:
2984                            raise JournalParseError(
2985                                "A transition unification entry may only"
2986                                f" have one argument, but we got"
2987                                f" {len(eParts)}."
2988                            )
2989                        self.recordUnifyReciprocal(eParts[0])
2990                    else:
2991                        raise RuntimeError(
2992                            f"Invalid target type {eTarget} after check"
2993                            f" for unify entry!"
2994                        )
2995
2996                elif eType == 'obviate':
2997                    self.checkFormat(
2998                        "obviate",
2999                        dType,
3000                        eTarget,
3001                        eParts,
3002                        None,
3003                        3
3004                    )
3005                    transition, targetDecision, targetTransition = eParts
3006                    self.recordObviate(
3007                        transition,
3008                        pf.parseDecisionSpecifier(targetDecision),
3009                        targetTransition
3010                    )
3011
3012                elif eType == 'extinguish':
3013                    self.checkFormat(
3014                        "extinguish",
3015                        dType,
3016                        eTarget,
3017                        eParts,
3018                        (
3019                            None,
3020                            'decisionPart',
3021                            'transitionPart',
3022                            'reciprocalPart',
3023                            'bothPart'
3024                        ),
3025                        1
3026                    )
3027                    if eTarget is None:
3028                        eTarget = 'bothPart'
3029                    if eTarget == 'decisionPart':
3030                        self.recordExtinguishDecision(
3031                            pf.parseDecisionSpecifier(eParts[0])
3032                        )
3033                    elif eTarget == 'transitionPart':
3034                        transition = eParts[0]
3035                        here = self.definiteDecisionTarget()
3036                        self.recordExtinguishTransition(
3037                            here,
3038                            transition,
3039                            False
3040                        )
3041                    elif eTarget == 'bothPart':
3042                        transition = eParts[0]
3043                        here = self.definiteDecisionTarget()
3044                        self.recordExtinguishTransition(
3045                            here,
3046                            transition,
3047                            True
3048                        )
3049                    else:  # Must be reciprocalPart
3050                        transition = eParts[0]
3051                        here = self.definiteDecisionTarget()
3052                        now = self.exploration.getSituation()
3053                        rPair = now.graph.getReciprocalPair(here, transition)
3054                        if rPair is None:
3055                            raise JournalParseError(
3056                                f"Attempted to extinguish the"
3057                                f" reciprocal of transition"
3058                                f" {transition!r} which "
3059                                f" has no reciprocal (or which"
3060                                f" doesn't exist from decision"
3061                                f" {now.graph.identityOf(here)})."
3062                            )
3063
3064                        self.recordExtinguishTransition(
3065                            rPair[0],
3066                            rPair[1],
3067                            deleteReciprocal=False
3068                        )
3069
3070                elif eType == 'complicate':
3071                    self.checkFormat(
3072                        "complicate",
3073                        dType,
3074                        eTarget,
3075                        eParts,
3076                        None,
3077                        4
3078                    )
3079                    target, newName, newReciprocal, newRR = eParts
3080                    self.recordComplicate(
3081                        target,
3082                        newName,
3083                        newReciprocal,
3084                        newRR
3085                    )
3086
3087                elif eType == 'status':
3088                    self.checkFormat(
3089                        "status",
3090                        dType,
3091                        eTarget,
3092                        eParts,
3093                        (None, 'unfinishedPart'),
3094                        {0, 1}
3095                    )
3096                    dID = self.definiteDecisionTarget()
3097                    # Default status to use
3098                    status: base.ExplorationStatus = 'explored'
3099                    # Figure out whether a valid status was provided
3100                    if len(eParts) > 0:
3101                        assert len(eParts) == 1
3102                        eArgs = get_args(base.ExplorationStatus)
3103                        if eParts[0] not in eArgs:
3104                            raise JournalParseError(
3105                                f"Invalid explicit exploration status"
3106                                f" {eParts[0]!r}. Exploration statuses"
3107                                f" must be one of:\n{eArgs!r}"
3108                            )
3109                        status = cast(base.ExplorationStatus, eParts[0])
3110                    # Record new status, as long as we have an explicit
3111                    # status OR 'unfinishedPart' was not given. If
3112                    # 'unfinishedPart' was given, also block auto updates
3113                    if eTarget == 'unfinishedPart':
3114                        if len(eParts) > 0:
3115                            self.recordStatus(dID, status)
3116                        self.recordObservationIncomplete(dID)
3117                    else:
3118                        self.recordStatus(dID, status)
3119
3120                elif eType == 'revert':
3121                    self.checkFormat(
3122                        "revert",
3123                        dType,
3124                        eTarget,
3125                        eParts,
3126                        None,
3127                        None
3128                    )
3129                    aspects: List[str]
3130                    if len(eParts) == 0:
3131                        slot = base.DEFAULT_SAVE_SLOT
3132                        aspects = []
3133                    else:
3134                        slot = eParts[0]
3135                        aspects = eParts[1:]
3136                    aspectsSet = set(aspects)
3137                    if len(aspectsSet) == 0:
3138                        aspectsSet = self.preferences['revertAspects']
3139                    self.recordRevert(slot, aspectsSet, decisionType=dType)
3140
3141                elif eType == 'fulfills':
3142                    self.checkFormat(
3143                        "fulfills",
3144                        dType,
3145                        eTarget,
3146                        eParts,
3147                        None,
3148                        2
3149                    )
3150                    condition = pf.parseRequirement(eParts[0])
3151                    fReq = pf.parseRequirement(eParts[1])
3152                    fulfills: Union[
3153                        base.Capability,
3154                        Tuple[base.MechanismID, base.MechanismState]
3155                    ]
3156                    if isinstance(fReq, base.ReqCapability):
3157                        fulfills = fReq.capability
3158                    elif isinstance(fReq, base.ReqMechanism):
3159                        mState = fReq.reqState
3160                        if isinstance(fReq.mechanism, int):
3161                            mID = fReq.mechanism
3162                        else:
3163                            graph = self.exploration.getSituation().graph
3164                            mID = graph.resolveMechanism(
3165                                fReq.mechanism,
3166                                {self.definiteDecisionTarget()}
3167                            )
3168                        fulfills = (mID, mState)
3169                    else:
3170                        raise JournalParseError(
3171                            f"Cannot fulfill {eParts[1]!r} because it"
3172                            f" doesn't specify either a capability or a"
3173                            f" mechanism/state pair."
3174                        )
3175                    self.recordFulfills(condition, fulfills)
3176
3177                elif eType == 'relative':
3178                    self.checkFormat(
3179                        "relative",
3180                        dType,
3181                        eTarget,
3182                        eParts,
3183                        (None, 'transitionPart'),
3184                        (0, 1, 2)
3185                    )
3186                    if (
3187                        len(eParts) == 1
3188                    and eParts[0] == self.parseFormat.markerFor(
3189                            'relative'
3190                        )
3191                    ):
3192                        self.relative()
3193                    elif eTarget == 'transitionPart':
3194                        self.relative(None, *eParts)
3195                    else:
3196                        self.relative(*eParts)
3197
3198                else:
3199                    raise NotImplementedError(
3200                        f"Unrecognized event type {eType!r}."
3201                    )
3202        except Exception as e:
3203            raise LocatedJournalParseError(
3204                journalText,
3205                self.parseIndices[-1],
3206                e
3207            )
3208        finally:
3209            self.journalTexts.pop()
3210            self.parseIndices.pop()

Ingests one or more journal blocks in text format (as a multi-line string) and updates the exploration being built by this observer, as well as updating internal state.

This method can be called multiple times to process a longer journal incrementally including line-by-line.

The journalText and parseIndex fields will be updated during parsing to support contextual error messages and warnings.

Example:

>>> obs = JournalObserver()
>>> oldWarn = core.WARN_OF_NAME_COLLISIONS
>>> try:
...     obs.observe('''\
... S Room1::start
... zz Region
... o nope
...   q power|tokens*3
... o unexplored
... o onwards
... x onwards sub_room backwards
... t backwards
... o down
...
... x down Room2::middle up
... a box
...   At deactivate
...   At gain tokens*1
... o left
... o right
...   gt blue
...
... x right Room3::middle left
... o right
... a miniboss
...   At deactivate
...   At gain power
... x right - left
... o ledge
...   q tall
... t left
... t left
... t up
...
... x nope secret back
... ''')
... finally:
...     core.WARN_OF_NAME_COLLISIONS = oldWarn
>>> e = obs.getExploration()
>>> len(e)
13
>>> g = e.getSituation().graph
>>> len(g)
9
>>> def showDestinations(g, r):
...     if isinstance(r, str):
...         r = obs.parseFormat.parseDecisionSpecifier(r)
...     d = g.destinationsFrom(r)
...     for outgoing in sorted(d):
...         req = g.getTransitionRequirement(r, outgoing)
...         if req is None or req == base.ReqNothing():
...             req = ''
...         else:
...             req = ' ' + repr(req)
...         print(outgoing, g.identityOf(d[outgoing]) + req)
...
>>> "start" in g
False
>>> showDestinations(g, "Room1::start")
down 4 (Room2::middle)
nope 1 (Room1::secret) ReqAny([ReqCapability('power'), ReqTokens('tokens', 3)])
onwards 3 (Room1::sub_room)
unexplored 2 (_u.1)
>>> showDestinations(g, "Room1::secret")
back 0 (Room1::start)
>>> showDestinations(g, "Room1::sub_room")
backwards 0 (Room1::start)
>>> showDestinations(g, "Room2::middle")
box 4 (Room2::middle)
left 5 (_u.4)
right 6 (Room3::middle)
up 0 (Room1::start)
>>> g.transitionTags(4, "right")
{'blue': 1}
>>> showDestinations(g, "Room3::middle")
left 4 (Room2::middle)
miniboss 6 (Room3::middle)
right 7 (Room3::-)
>>> showDestinations(g, "Room3::-")
ledge 8 (_u.7) ReqCapability('tall')
left 6 (Room3::middle)
>>> showDestinations(g, "_u.7")
return 7 (Room3::-)
>>> e.getActiveDecisions()
{1}
>>> g.identityOf(1)
'1 (Room1::secret)'

Note that there are plenty of other annotations not shown in this example; see DEFAULT_FORMAT for the default mapping from journal entry types to markers, and see JournalEntryType for the explanation for each entry type.

Most entries start with a marker (which includes one character for the type and possibly one for the target) followed by a single space, and everything after that is the content of the entry.

def defineAlias(self, name: str, parameters: Sequence[str], commands: str) -> None:
3212    def defineAlias(
3213        self,
3214        name: str,
3215        parameters: Sequence[str],
3216        commands: str
3217    ) -> None:
3218        """
3219        Defines an alias: a block of commands that can be played back
3220        later using the 'custom' command, with parameter substitutions.
3221
3222        If an alias with the specified name already existed, it will be
3223        replaced.
3224
3225        Each of the listed parameters must be supplied when invoking the
3226        alias, and where they appear within curly braces in the commands
3227        string, they will be substituted in. Additional names starting
3228        with '_' plus an optional integer will also be substituted with
3229        unique names (see `nextUniqueName`), with the same name being
3230        used for every instance that shares the same numerical suffix
3231        within each application of the command. Substitution points must
3232        not include spaces; if an open curly brace is followed by
3233        whitesapce or where a close curly brace is proceeded by
3234        whitespace, those will be treated as normal curly braces and will
3235        not create a substitution point.
3236
3237        For example:
3238
3239        >>> o = JournalObserver()
3240        >>> o.defineAlias(
3241        ...     'hintRoom',
3242        ...     ['name'],
3243        ...     'o {_5}\\nx {_5} {name} {_5}\\ngd hint\\nt {_5}'
3244        ... )  # _5 to show that the suffix doesn't matter if it's consistent
3245        >>> o.defineAlias(
3246        ...     'trade',
3247        ...     ['gain', 'lose'],
3248        ...     'A { gain {gain}; lose {lose} }'
3249        ... )  # note outer curly braces
3250        >>> o.recordStart('start')
3251        >>> o.deployAlias('hintRoom', ['hint1'])
3252        >>> o.deployAlias('hintRoom', ['hint2'])
3253        >>> o.deployAlias('trade', ['flower*1', 'coin*1'])
3254        >>> e = o.getExploration()
3255        >>> e.movementAtStep(0)
3256        (None, None, 0)
3257        >>> e.movementAtStep(1)
3258        (0, '_0', 1)
3259        >>> e.movementAtStep(2)
3260        (1, '_0', 0)
3261        >>> e.movementAtStep(3)
3262        (0, '_1', 2)
3263        >>> e.movementAtStep(4)
3264        (2, '_1', 0)
3265        >>> g = e.getSituation().graph
3266        >>> len(g)
3267        3
3268        >>> g.namesListing([0, 1, 2])
3269        '  0 (start)\\n  1 (hint1)\\n  2 (hint2)\\n'
3270        >>> g.decisionTags('hint1')
3271        {'hint': 1}
3272        >>> g.decisionTags('hint2')
3273        {'hint': 1}
3274        >>> e.tokenCountNow('coin')
3275        -1
3276        >>> e.tokenCountNow('flower')
3277        1
3278        """
3279        # Going to be formatted twice so {{{{ -> {{ -> {
3280        # TODO: Move this logic into deployAlias
3281        commands = re.sub(r'{(\s)', r'{{{{\1', commands)
3282        commands = re.sub(r'(\s)}', r'\1}}}}', commands)
3283        self.aliases[name] = (list(parameters), commands)

Defines an alias: a block of commands that can be played back later using the 'custom' command, with parameter substitutions.

If an alias with the specified name already existed, it will be replaced.

Each of the listed parameters must be supplied when invoking the alias, and where they appear within curly braces in the commands string, they will be substituted in. Additional names starting with '_' plus an optional integer will also be substituted with unique names (see nextUniqueName), with the same name being used for every instance that shares the same numerical suffix within each application of the command. Substitution points must not include spaces; if an open curly brace is followed by whitesapce or where a close curly brace is proceeded by whitespace, those will be treated as normal curly braces and will not create a substitution point.

For example:

>>> o = JournalObserver()
>>> o.defineAlias(
...     'hintRoom',
...     ['name'],
...     'o {_5}\nx {_5} {name} {_5}\ngd hint\nt {_5}'
... )  # _5 to show that the suffix doesn't matter if it's consistent
>>> o.defineAlias(
...     'trade',
...     ['gain', 'lose'],
...     'A { gain {gain}; lose {lose} }'
... )  # note outer curly braces
>>> o.recordStart('start')
>>> o.deployAlias('hintRoom', ['hint1'])
>>> o.deployAlias('hintRoom', ['hint2'])
>>> o.deployAlias('trade', ['flower*1', 'coin*1'])
>>> e = o.getExploration()
>>> e.movementAtStep(0)
(None, None, 0)
>>> e.movementAtStep(1)
(0, '_0', 1)
>>> e.movementAtStep(2)
(1, '_0', 0)
>>> e.movementAtStep(3)
(0, '_1', 2)
>>> e.movementAtStep(4)
(2, '_1', 0)
>>> g = e.getSituation().graph
>>> len(g)
3
>>> g.namesListing([0, 1, 2])
'  0 (start)\n  1 (hint1)\n  2 (hint2)\n'
>>> g.decisionTags('hint1')
{'hint': 1}
>>> g.decisionTags('hint2')
{'hint': 1}
>>> e.tokenCountNow('coin')
-1
>>> e.tokenCountNow('flower')
1
def deployAlias(self, name: str, arguments: Sequence[str]) -> None:
3285    def deployAlias(self, name: str, arguments: Sequence[str]) -> None:
3286        """
3287        Deploys an alias, taking its command string and substituting in
3288        the provided argument values for each of the alias' parameters,
3289        plus any unique names that it requests. Substitution happens
3290        first for named arguments and then for unique strings, so named
3291        arguments of the form '{_-n-}' where -n- is an integer will end
3292        up being substituted for unique names. Sets of curly braces that
3293        have at least one space immediately after the open brace or
3294        immediately before the closing brace will be interpreted as
3295        normal curly braces, NOT as the start/end of a substitution
3296        point.
3297
3298        There are a few automatic arguments (although these can be
3299        overridden if the alias definition uses the same argument name
3300        explicitly):
3301        - '__here__' will substitute to the ID of the current decision
3302            based on the `ObservationContext`, or will generate an error
3303            if there is none. This is the current decision at the moment
3304            the alias is deployed, NOT based on steps within the alias up
3305            to the substitution point.
3306        - '__hereName__' will substitute the name of the current
3307            decision.
3308        - '__zone__' will substitute the name of the alphabetically
3309            first level-0 zone ancestor of the current decision.
3310        - '__region__' will substitute the name of the alphabetically
3311            first level-1 zone ancestor of the current decision.
3312        - '__transition__' will substitute to the name of the current
3313            transition, or will generate an error if there is none. Note
3314            that the current transition is sometimes NOT a valid
3315            transition from the current decision, because when you take
3316            a transition, that transition's name is current but the
3317            current decision is its destination.
3318        - '__reciprocal__' will substitute to the name of the reciprocal
3319            of the current transition.
3320        - '__trBase__' will substitute to the decision from which the
3321            current transition departs.
3322        - '__trDest__' will substitute to the destination of the current
3323            transition.
3324        - '__prev__' will substitute to the ID of the primary decision in
3325            the previous exploration step, (which is NOT always the
3326            previous current decision of the `ObservationContext`,
3327            especially in relative mode).
3328        - '__across__-name-__' where '-name-' is a transition name will
3329            substitute to the decision reached by traversing that
3330            transition from the '__here__' decision. Note that the
3331            transition name used must be a valid Python identifier.
3332
3333        Raises a `JournalParseError` if the specified alias does not
3334        exist, or if the wrong number of parameters has been supplied.
3335
3336        See `defineAlias` for an example.
3337        """
3338        # Fetch the alias
3339        alias = self.aliases.get(name)
3340        if alias is None:
3341            raise JournalParseError(
3342                f"Alias {name!r} has not been defined yet."
3343            )
3344        paramNames, commands = alias
3345
3346        # Check arguments
3347        arguments = list(arguments)
3348        if len(arguments) != len(paramNames):
3349            raise JournalParseError(
3350                f"Alias {name!r} requires {len(paramNames)} parameters,"
3351                f" but you supplied {len(arguments)}."
3352            )
3353
3354        # Find unique names
3355        uniques = set([
3356            match.strip('{}')
3357            for match in re.findall('{_[0-9]*}', commands)
3358        ])
3359
3360        # Build substitution dictionary that passes through uniques
3361        firstWave = {unique: '{' + unique + '}' for unique in uniques}
3362
3363        # Fill in each non-overridden & requested auto variable:
3364        graph = self.exploration.getSituation().graph
3365        if '{__here__}' in commands and '__here__' not in firstWave:
3366            firstWave['__here__'] = self.definiteDecisionTarget()
3367        if '{__hereName__}' in commands and '__hereName__' not in firstWave:
3368            firstWave['__hereName__'] = graph.nameFor(
3369                self.definiteDecisionTarget()
3370            )
3371        if '{__zone__}' in commands and '__zone__' not in firstWave:
3372            baseDecision = self.definiteDecisionTarget()
3373            parents = sorted(
3374                ancestor
3375                for ancestor in graph.zoneAncestors(baseDecision)
3376                if graph.zoneHierarchyLevel(ancestor) == 0
3377            )
3378            if len(parents) == 0:
3379                raise JournalParseError(
3380                    f"Used __zone__ in a macro, but the current"
3381                    f" decision {graph.identityOf(baseDecision)} is not"
3382                    f" in any level-0 zones."
3383                )
3384            firstWave['__zone__'] = parents[0]
3385        if '{__region__}' in commands and '__region__' not in firstWave:
3386            baseDecision = self.definiteDecisionTarget()
3387            grandparents = sorted(
3388                ancestor
3389                for ancestor in graph.zoneAncestors(baseDecision)
3390                if graph.zoneHierarchyLevel(ancestor) == 1
3391            )
3392            if len(grandparents) == 0:
3393                raise JournalParseError(
3394                    f"Used __region__ in a macro, but the current"
3395                    f" decision {graph.identityOf(baseDecision)} is not"
3396                    f" in any level-1 zones."
3397                )
3398            firstWave['__region__'] = grandparents[0]
3399        if (
3400            '{__transition__}' in commands
3401        and '__transition__' not in firstWave
3402        ):
3403            ctxTr = self.currentTransitionTarget()
3404            if ctxTr is None:
3405                raise JournalParseError(
3406                    f"Can't deploy alias {name!r} because it has a"
3407                    f" __transition__ auto-slot but there is no current"
3408                    f" transition at the current exploration step."
3409                )
3410            firstWave['__transition__'] = ctxTr[1]
3411        if '{__trBase__}' in commands and '__trBase__' not in firstWave:
3412            ctxTr = self.currentTransitionTarget()
3413            if ctxTr is None:
3414                raise JournalParseError(
3415                    f"Can't deploy alias {name!r} because it has a"
3416                    f" __transition__ auto-slot but there is no current"
3417                    f" transition at the current exploration step."
3418                )
3419            firstWave['__trBase__'] = ctxTr[0]
3420        if '{__trDest__}' in commands and '__trDest__' not in firstWave:
3421            ctxTr = self.currentTransitionTarget()
3422            if ctxTr is None:
3423                raise JournalParseError(
3424                    f"Can't deploy alias {name!r} because it has a"
3425                    f" __transition__ auto-slot but there is no current"
3426                    f" transition at the current exploration step."
3427                )
3428            firstWave['__trDest__'] = graph.getDestination(*ctxTr)
3429        if (
3430            '{__reciprocal__}' in commands
3431        and '__reciprocal__' not in firstWave
3432        ):
3433            ctxTr = self.currentTransitionTarget()
3434            if ctxTr is None:
3435                raise JournalParseError(
3436                    f"Can't deploy alias {name!r} because it has a"
3437                    f" __transition__ auto-slot but there is no current"
3438                    f" transition at the current exploration step."
3439                )
3440            firstWave['__reciprocal__'] = graph.getReciprocal(*ctxTr)
3441        if '{__prev__}' in commands and '__prev__' not in firstWave:
3442            try:
3443                prevPrimary = self.exploration.primaryDecision(-2)
3444            except IndexError:
3445                raise JournalParseError(
3446                    f"Can't deploy alias {name!r} because it has a"
3447                    f" __prev__ auto-slot but there is no previous"
3448                    f" exploration step."
3449                )
3450            if prevPrimary is None:
3451                raise JournalParseError(
3452                    f"Can't deploy alias {name!r} because it has a"
3453                    f" __prev__ auto-slot but there is no primary"
3454                    f" decision for the previous exploration step."
3455                )
3456            firstWave['__prev__'] = prevPrimary
3457
3458        here = self.currentDecisionTarget()
3459        for match in re.findall(r'{__across__[^ ]\+__}', commands):
3460            if here is None:
3461                raise JournalParseError(
3462                    f"Can't deploy alias {name!r} because it has an"
3463                    f" __across__ auto-slot but there is no current"
3464                    f" decision."
3465                )
3466            transition = match[11:-3]
3467            dest = graph.getDestination(here, transition)
3468            firstWave[f'__across__{transition}__'] = dest
3469        firstWave.update({
3470            param: value
3471            for (param, value) in zip(paramNames, arguments)
3472        })
3473
3474        # Substitute parameter values
3475        commands = commands.format(**firstWave)
3476
3477        uniques = set([
3478            match.strip('{}')
3479            for match in re.findall('{_[0-9]*}', commands)
3480        ])
3481
3482        # Substitute for remaining unique names
3483        uniqueValues = {
3484            unique: self.nextUniqueName()
3485            for unique in sorted(uniques)  # sort for stability
3486        }
3487        commands = commands.format(**uniqueValues)
3488
3489        # Now run the commands
3490        self.observe(commands)

Deploys an alias, taking its command string and substituting in the provided argument values for each of the alias' parameters, plus any unique names that it requests. Substitution happens first for named arguments and then for unique strings, so named arguments of the form '{_-n-}' where -n- is an integer will end up being substituted for unique names. Sets of curly braces that have at least one space immediately after the open brace or immediately before the closing brace will be interpreted as normal curly braces, NOT as the start/end of a substitution point.

There are a few automatic arguments (although these can be overridden if the alias definition uses the same argument name explicitly):

  • '__here__' will substitute to the ID of the current decision based on the ObservationContext, or will generate an error if there is none. This is the current decision at the moment the alias is deployed, NOT based on steps within the alias up to the substitution point.
  • '__hereName__' will substitute the name of the current decision.
  • '__zone__' will substitute the name of the alphabetically first level-0 zone ancestor of the current decision.
  • '__region__' will substitute the name of the alphabetically first level-1 zone ancestor of the current decision.
  • '__transition__' will substitute to the name of the current transition, or will generate an error if there is none. Note that the current transition is sometimes NOT a valid transition from the current decision, because when you take a transition, that transition's name is current but the current decision is its destination.
  • '__reciprocal__' will substitute to the name of the reciprocal of the current transition.
  • '__trBase__' will substitute to the decision from which the current transition departs.
  • '__trDest__' will substitute to the destination of the current transition.
  • '__prev__' will substitute to the ID of the primary decision in the previous exploration step, (which is NOT always the previous current decision of the ObservationContext, especially in relative mode).
  • '__across__-name-__' where '-name-' is a transition name will substitute to the decision reached by traversing that transition from the '__here__' decision. Note that the transition name used must be a valid Python identifier.

Raises a JournalParseError if the specified alias does not exist, or if the wrong number of parameters has been supplied.

See defineAlias for an example.

def doDebug( self, action: Literal['here', 'transition', 'destinations', 'steps', 'decisions', 'active', 'primary', 'saved', 'inventory', 'mechanisms', 'equivalences'], arg: str = '') -> None:
3492    def doDebug(self, action: DebugAction, arg: str = "") -> None:
3493        """
3494        Prints out a debugging message to stderr. Useful for figuring
3495        out parsing errors. See also `DebugAction` and
3496        `JournalEntryType. Certain actions allow an extra argument. The
3497        action will be one of:
3498        - 'here': prints the ID and name of the current decision, or
3499            `None` if there isn't one.
3500        - 'transition': prints the name of the current transition, or `None`
3501            if there isn't one.
3502        - 'destinations': prints the ID and name of the current decision,
3503            followed by the names of each outgoing transition and their
3504            destinations. Includes any requirements the transitions have.
3505            If an extra argument is supplied, looks up that decision and
3506            prints destinations from there.
3507        - 'steps': prints out the number of steps in the current exploration,
3508            plus the number since the most recent use of 'steps'.
3509        - 'decisions': prints out the number of decisions in the current
3510            graph, plus the number added/removed since the most recent use of
3511            'decisions'.
3512        - 'active': prints out the names listing of all currently active
3513            decisions.
3514        - 'primary': prints out the identity of the current primary
3515            decision, or None if there is none.
3516        - 'saved': prints out the primary decision for the state saved in
3517            the default save slot, or for a specific save slot if a
3518            second argument is given.
3519        - 'inventory': Displays all current capabilities, tokens, and
3520            skills.
3521        - 'mechanisms': Displays all current mechanisms and their states.
3522        - 'equivalences': Displays all current equivalences, along with
3523            whether or not they're active.
3524        """
3525        graph = self.exploration.getSituation().graph
3526        if arg != '' and action not in ('destinations', 'saved'):
3527            raise JournalParseError(
3528                f"Invalid debug command {action!r} with arg {arg!r}:"
3529                f" Only 'destination' and 'saved' actions may include a"
3530                f" second argument."
3531            )
3532        if action == "here":
3533            dt = self.currentDecisionTarget()
3534            print(
3535                f"Current decision is: {graph.identityOf(dt)}",
3536                file=sys.stderr
3537            )
3538        elif action == "transition":
3539            tTarget = self.currentTransitionTarget()
3540            if tTarget is None:
3541                print("Current transition is: None", file=sys.stderr)
3542            else:
3543                tDecision, tTransition = tTarget
3544                print(
3545                    (
3546                        f"Current transition is {tTransition!r} from"
3547                        f" {graph.identityOf(tDecision)}."
3548                    ),
3549                    file=sys.stderr
3550                )
3551        elif action == "destinations":
3552            if arg == "":
3553                here = self.currentDecisionTarget()
3554                adjective = "current"
3555                if here is None:
3556                    print("There is no current decision.", file=sys.stderr)
3557            else:
3558                adjective = "target"
3559                dHint = None
3560                zHint = None
3561                tSpec = self.decisionTargetSpecifier()
3562                if tSpec is not None:
3563                    dHint = tSpec.domain
3564                    zHint = tSpec.zone
3565                here = self.exploration.getSituation().graph.getDecision(
3566                    self.parseFormat.parseDecisionSpecifier(arg),
3567                    zoneHint=zHint,
3568                    domainHint=dHint,
3569                )
3570                if here is None:
3571                    print("Decision {arg!r} was not found.", file=sys.stderr)
3572
3573            if here is not None:
3574                dests = graph.destinationsFrom(here)
3575                outgoing = {
3576                    route: dests[route]
3577                    for route in dests
3578                    if dests[route] != here
3579                }
3580                actions = {
3581                    route: dests[route]
3582                    for route in dests
3583                    if dests[route] == here
3584                }
3585                print(
3586                    f"The {adjective} decision is: {graph.identityOf(here)}",
3587                    file=sys.stderr
3588                )
3589                if len(outgoing) == 0:
3590                    print(
3591                        (
3592                            "There are no outgoing transitions at this"
3593                            " decision."
3594                        ),
3595                        file=sys.stderr
3596                    )
3597                else:
3598                    print(
3599                        (
3600                            f"There are {len(outgoing)} outgoing"
3601                            f" transition(s):"
3602                        ),
3603                        file=sys.stderr
3604                    )
3605                for transition in outgoing:
3606                    destination = outgoing[transition]
3607                    req = graph.getTransitionRequirement(
3608                        here,
3609                        transition
3610                    )
3611                    rstring = ''
3612                    if req != base.ReqNothing():
3613                        rstring = f" (requires {req})"
3614                    print(
3615                        (
3616                            f"  {transition!r} ->"
3617                            f" {graph.identityOf(destination)}{rstring}"
3618                        ),
3619                        file=sys.stderr
3620                    )
3621
3622                if len(actions) > 0:
3623                    print(
3624                        f"There are {len(actions)} actions:",
3625                        file=sys.stderr
3626                    )
3627                    for oneAction in actions:
3628                        req = graph.getTransitionRequirement(
3629                            here,
3630                            oneAction
3631                        )
3632                        rstring = ''
3633                        if req != base.ReqNothing():
3634                            rstring = f" (requires {req})"
3635                        print(
3636                            f"  {oneAction!r}{rstring}",
3637                            file=sys.stderr
3638                        )
3639
3640        elif action == "steps":
3641            steps = len(self.getExploration())
3642            if self.prevSteps is not None:
3643                elapsed = steps - cast(int, self.prevSteps)
3644                print(
3645                    (
3646                        f"There are {steps} steps in the current"
3647                        f" exploration (which is {elapsed} more than"
3648                        f" there were at the previous check)."
3649                    ),
3650                    file=sys.stderr
3651                )
3652            else:
3653                print(
3654                    (
3655                        f"There are {steps} steps in the current"
3656                        f" exploration."
3657                    ),
3658                    file=sys.stderr
3659                )
3660            self.prevSteps = steps
3661
3662        elif action == "decisions":
3663            count = len(self.getExploration().getSituation().graph)
3664            if self.prevDecisions is not None:
3665                elapsed = count - self.prevDecisions
3666                print(
3667                    (
3668                        f"There are {count} decisions in the current"
3669                        f" graph (which is {elapsed} more than there"
3670                        f" were at the previous check)."
3671                    ),
3672                    file=sys.stderr
3673                )
3674            else:
3675                print(
3676                    (
3677                        f"There are {count} decisions in the current"
3678                        f" graph."
3679                    ),
3680                    file=sys.stderr
3681                )
3682            self.prevDecisions = count
3683        elif action == "active":
3684            active = self.exploration.getActiveDecisions()
3685            now = self.exploration.getSituation()
3686            print(
3687                "Active decisions:\n",
3688                now.graph.namesListing(active),
3689                file=sys.stderr
3690            )
3691        elif action == "primary":
3692            e = self.exploration
3693            primary = e.primaryDecision()
3694            if primary is None:
3695                pr = "None"
3696            else:
3697                pr = e.getSituation().graph.identityOf(primary)
3698            print(f"Primary decision: {pr}", file=sys.stderr)
3699        elif action == "saved":
3700            now = self.exploration.getSituation()
3701            slot = base.DEFAULT_SAVE_SLOT
3702            if arg != "":
3703                slot = arg
3704            saved = now.saves.get(slot)
3705            if saved is None:
3706                print(f"Slot {slot!r} has no saved data.", file=sys.stderr)
3707            else:
3708                savedGraph, savedState = saved
3709                savedPrimary = savedGraph.identityOf(
3710                    savedState['primaryDecision']
3711                )
3712                print(f"Saved at decision: {savedPrimary}", file=sys.stderr)
3713        elif action == "inventory":
3714            now = self.exploration.getSituation()
3715            commonCap = now.state['common']['capabilities']
3716            activeCap = now.state['contexts'][now.state['activeContext']][
3717                'capabilities'
3718            ]
3719            merged = base.mergeCapabilitySets(commonCap, activeCap)
3720            capCount = len(merged['capabilities'])
3721            tokCount = len(merged['tokens'])
3722            skillCount = len(merged['skills'])
3723            print(
3724                (
3725                    f"{capCount} capability/ies, {tokCount} token type(s),"
3726                    f" and {skillCount} skill(s)"
3727                ),
3728                file=sys.stderr
3729            )
3730            if capCount > 0:
3731                print("Capabilities (alphabetical order):", file=sys.stderr)
3732                for cap in sorted(merged['capabilities']):
3733                    print(f"  {cap!r}", file=sys.stderr)
3734            if tokCount > 0:
3735                print("Tokens (alphabetical order):", file=sys.stderr)
3736                for tok in sorted(merged['tokens']):
3737                    print(
3738                        f"  {tok!r}: {merged['tokens'][tok]}",
3739                        file=sys.stderr
3740                    )
3741            if skillCount > 0:
3742                print("Skill levels (alphabetical order):", file=sys.stderr)
3743                for skill in sorted(merged['skills']):
3744                    print(
3745                        f"  {skill!r}: {merged['skills'][skill]}",
3746                        file=sys.stderr
3747                    )
3748        elif action == "mechanisms":
3749            now = self.exploration.getSituation()
3750            grpah = now.graph
3751            mechs = now.state['mechanisms']
3752            inGraph = set(graph.mechanisms) - set(mechs)
3753            print(
3754                (
3755                    f"{len(mechs)} mechanism(s) in known states;"
3756                    f" {len(inGraph)} additional mechanism(s) in the"
3757                    f" default state"
3758                ),
3759                file=sys.stderr
3760            )
3761            if len(mechs) > 0:
3762                print("Mechanism(s) in known state(s):", file=sys.stderr)
3763                for mID in sorted(mechs):
3764                    mState = mechs[mID]
3765                    whereID, mName = graph.mechanisms[mID]
3766                    if whereID is None:
3767                        whereStr = " (global)"
3768                    else:
3769                        domain = graph.domainFor(whereID)
3770                        whereStr = f" at {graph.identityOf(whereID)}"
3771                    print(
3772                        f"  {mName}:{mState!r} - {mID}{whereStr}",
3773                        file=sys.stderr
3774                    )
3775            if len(inGraph) > 0:
3776                print("Mechanism(s) in the default state:", file=sys.stderr)
3777                for mID in sorted(inGraph):
3778                    whereID, mName = graph.mechanisms[mID]
3779                    if whereID is None:
3780                        whereStr = " (global)"
3781                    else:
3782                        domain = graph.domainFor(whereID)
3783                        whereStr = f" at {graph.identityOf(whereID)}"
3784                    print(f"  {mID} - {mName}){whereStr}", file=sys.stderr)
3785        elif action == "equivalences":
3786            now = self.exploration.getSituation()
3787            eqDict = now.graph.equivalences
3788            if len(eqDict) > 0:
3789                print(f"{len(eqDict)} equivalences:", file=sys.stderr)
3790                for hasEq in eqDict:
3791                    if isinstance(hasEq, tuple):
3792                        assert len(hasEq) == 2
3793                        assert isinstance(hasEq[0], base.MechanismID)
3794                        assert isinstance(hasEq[1], base.MechanismState)
3795                        mID, mState = hasEq
3796                        mDetails = now.graph.mechanismDetails(mID)
3797                        assert mDetails is not None
3798                        mWhere, mName = mDetails
3799                        if mWhere is None:
3800                            whereStr = " (global)"
3801                        else:
3802                            whereStr = f" at {graph.identityOf(mWhere)}"
3803                        eqStr = f"{mName}:{mState!r} - {mID}{whereStr}"
3804                    else:
3805                        assert isinstance(hasEq, base.Capability)
3806                        eqStr = hasEq
3807                    eqSet = eqDict[hasEq]
3808                    print(
3809                        f"  {eqStr} has {len(eqSet)} equivalence(s):",
3810                        file=sys.stderr
3811                    )
3812                    for eqReq in eqDict[hasEq]:
3813                        print(f"    {eqReq}", file=sys.stderr)
3814            else:
3815                print(
3816                    "There are no equivalences right now.",
3817                    file=sys.stderr
3818                )
3819        else:
3820            raise JournalParseError(
3821                f"Invalid debug command: {action!r}"
3822            )

Prints out a debugging message to stderr. Useful for figuring out parsing errors. See also DebugAction and `JournalEntryType. Certain actions allow an extra argument. The action will be one of:

  • 'here': prints the ID and name of the current decision, or None if there isn't one.
  • 'transition': prints the name of the current transition, or None if there isn't one.
  • 'destinations': prints the ID and name of the current decision, followed by the names of each outgoing transition and their destinations. Includes any requirements the transitions have. If an extra argument is supplied, looks up that decision and prints destinations from there.
  • 'steps': prints out the number of steps in the current exploration, plus the number since the most recent use of 'steps'.
  • 'decisions': prints out the number of decisions in the current graph, plus the number added/removed since the most recent use of 'decisions'.
  • 'active': prints out the names listing of all currently active decisions.
  • 'primary': prints out the identity of the current primary decision, or None if there is none.
  • 'saved': prints out the primary decision for the state saved in the default save slot, or for a specific save slot if a second argument is given.
  • 'inventory': Displays all current capabilities, tokens, and skills.
  • 'mechanisms': Displays all current mechanisms and their states.
  • 'equivalences': Displays all current equivalences, along with whether or not they're active.
def recordStart( self, where: Union[str, exploration.base.DecisionSpecifier], decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'imposed') -> None:
3824    def recordStart(
3825        self,
3826        where: Union[base.DecisionName, base.DecisionSpecifier],
3827        decisionType: base.DecisionType = 'imposed'
3828    ) -> None:
3829        """
3830        Records the start of the exploration. Use only once in each new
3831        domain, as the very first action in that domain (possibly after
3832        some zone declarations). The contextual domain is used if the
3833        given `base.DecisionSpecifier` doesn't include a domain.
3834
3835        To create new decision points that are disconnected from the rest
3836        of the graph that aren't the first in their domain, use the
3837        `relative` method followed by `recordWarp`.
3838
3839        The default 'imposed' decision type can be overridden for the
3840        action that this generates.
3841        """
3842        if self.inRelativeMode:
3843            raise JournalParseError(
3844                "Can't start the exploration in relative mode."
3845            )
3846
3847        whereSpec: Union[base.DecisionID, base.DecisionSpecifier]
3848        if isinstance(where, base.DecisionName):
3849            whereSpec = self.parseFormat.parseDecisionSpecifier(where)
3850            if isinstance(whereSpec, base.DecisionID):
3851                raise JournalParseError(
3852                    f"Can't use a number for a decision name. Got:"
3853                    f" {where!r}"
3854                )
3855        else:
3856            whereSpec = where
3857
3858        if whereSpec.domain is None:
3859            whereSpec = base.DecisionSpecifier(
3860                domain=self.context['domain'],
3861                zone=whereSpec.zone,
3862                name=whereSpec.name
3863            )
3864        self.context['decision'] = self.exploration.start(
3865            whereSpec,
3866            decisionType=decisionType
3867        )

Records the start of the exploration. Use only once in each new domain, as the very first action in that domain (possibly after some zone declarations). The contextual domain is used if the given base.DecisionSpecifier doesn't include a domain.

To create new decision points that are disconnected from the rest of the graph that aren't the first in their domain, use the relative method followed by recordWarp.

The default 'imposed' decision type can be overridden for the action that this generates.

def recordObserveAction(self, name: str) -> None:
3869    def recordObserveAction(self, name: base.Transition) -> None:
3870        """
3871        Records the observation of an action at the current decision,
3872        which has the given name.
3873        """
3874        here = self.definiteDecisionTarget()
3875        self.exploration.getSituation().graph.addAction(here, name)
3876        self.context['transition'] = (here, name)

Records the observation of an action at the current decision, which has the given name.

def recordObserve( self, name: str, destination: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, reciprocal: Optional[str] = None) -> None:
3878    def recordObserve(
3879        self,
3880        name: base.Transition,
3881        destination: Optional[base.AnyDecisionSpecifier] = None,
3882        reciprocal: Optional[base.Transition] = None
3883    ) -> None:
3884        """
3885        Records the observation of a new option at the current decision.
3886
3887        If two or three arguments are given, the destination is still
3888        marked as unexplored, but is given a name (with two arguments)
3889        and the reciprocal transition is named (with three arguments).
3890
3891        When a name or decision specifier is used for the destination,
3892        the domain and/or level-0 zone of the current decision are
3893        filled in if the specifier is a name or doesn't have domain
3894        and/or zone info. The first alphabetical level-0 zone is used if
3895        the current decision is in more than one.
3896        """
3897        here = self.definiteDecisionTarget()
3898
3899        # Our observation matches `DiscreteExploration.observe` args
3900        obs: Union[
3901            Tuple[base.Transition],
3902            Tuple[base.Transition, base.AnyDecisionSpecifier],
3903            Tuple[
3904                base.Transition,
3905                base.AnyDecisionSpecifier,
3906                base.Transition
3907            ]
3908        ]
3909
3910        # If we have a destination, parse it as a decision specifier
3911        # (might be an ID)
3912        if isinstance(destination, str):
3913            destination = self.parseFormat.parseDecisionSpecifier(
3914                destination
3915            )
3916
3917        # If we started with a name or some other kind of decision
3918        # specifier, replace missing domain and/or zone info with info
3919        # from the current decision.
3920        if isinstance(destination, base.DecisionSpecifier):
3921            destination = base.spliceDecisionSpecifiers(
3922                destination,
3923                self.decisionTargetSpecifier()
3924            )
3925            # TODO: This is kinda janky because it only uses 1 zone,
3926            # whereas explore puts the new decision in all of them.
3927
3928        # Set up our observation argument
3929        if destination is not None:
3930            if reciprocal is not None:
3931                obs = (name, destination, reciprocal)
3932            else:
3933                obs = (name, destination)
3934        elif reciprocal is not None:
3935            # TODO: Allow this? (make the destination generic)
3936            raise JournalParseError(
3937                "You may not specify a reciprocal name without"
3938                " specifying a destination."
3939            )
3940        else:
3941            obs = (name,)
3942
3943        self.exploration.observe(here, *obs)
3944        self.context['transition'] = (here, name)

Records the observation of a new option at the current decision.

If two or three arguments are given, the destination is still marked as unexplored, but is given a name (with two arguments) and the reciprocal transition is named (with three arguments).

When a name or decision specifier is used for the destination, the domain and/or level-0 zone of the current decision are filled in if the specifier is a name or doesn't have domain and/or zone info. The first alphabetical level-0 zone is used if the current decision is in more than one.

def recordObservationIncomplete(self, decision: Union[int, exploration.base.DecisionSpecifier, str]):
3946    def recordObservationIncomplete(
3947        self,
3948        decision: base.AnyDecisionSpecifier
3949    ):
3950        """
3951        Marks a particular decision as being incompletely-observed.
3952        Normally whenever we leave a decision, we set its exploration
3953        status as 'explored' under the assumption that before moving on
3954        to another decision, we'll note down all of the options at this
3955        one first. Usually, to indicate further exploration
3956        possibilities in a room, you can include a transition, and you
3957        could even use `recordUnify` later to indicate that what seemed
3958        like a junction between two decisions really wasn't, and they
3959        should be merged. But in rare cases, it makes sense instead to
3960        indicate before you leave a decision that you expect to see more
3961        options there later, but you can't or won't observe them now.
3962        Once `recordObservationIncomplete` has been called, the default
3963        mechanism will never upgrade the decision to 'explored', and you
3964        will usually want to eventually call `recordStatus` to
3965        explicitly do that (which also removes it from the
3966        `dontFinalize` set that this method puts it in).
3967
3968        When called on a decision which already has exploration status
3969        'explored', this also sets the exploration status back to
3970        'exploring'.
3971        """
3972        e = self.exploration
3973        dID = e.getSituation().graph.resolveDecision(decision)
3974        if e.getExplorationStatus(dID) == 'explored':
3975            e.setExplorationStatus(dID, 'exploring')
3976        self.dontFinalize.add(dID)

Marks a particular decision as being incompletely-observed. Normally whenever we leave a decision, we set its exploration status as 'explored' under the assumption that before moving on to another decision, we'll note down all of the options at this one first. Usually, to indicate further exploration possibilities in a room, you can include a transition, and you could even use recordUnify later to indicate that what seemed like a junction between two decisions really wasn't, and they should be merged. But in rare cases, it makes sense instead to indicate before you leave a decision that you expect to see more options there later, but you can't or won't observe them now. Once recordObservationIncomplete has been called, the default mechanism will never upgrade the decision to 'explored', and you will usually want to eventually call recordStatus to explicitly do that (which also removes it from the dontFinalize set that this method puts it in).

When called on a decision which already has exploration status 'explored', this also sets the exploration status back to 'exploring'.

def recordStatus( self, decision: Union[int, exploration.base.DecisionSpecifier, str], status: Literal['unknown', 'hypothesized', 'noticed', 'exploring', 'explored'] = 'explored'):
3978    def recordStatus(
3979        self,
3980        decision: base.AnyDecisionSpecifier,
3981        status: base.ExplorationStatus = 'explored'
3982    ):
3983        """
3984        Explicitly records that a particular decision has the specified
3985        exploration status (default 'explored' meaning we think we've
3986        seen everything there). This helps analysts look for unexpected
3987        connections.
3988
3989        Note that normally, exploration statuses will be updated
3990        automatically whenever a decision is first observed (status
3991        'noticed'), first visited (status 'exploring') and first left
3992        behind (status 'explored'). However, using
3993        `recordObservationIncomplete` can prevent the automatic
3994        'explored' update.
3995
3996        This method also removes a decision's `dontFinalize` entry,
3997        although it's probably no longer relevant in any case.
3998        TODO: Still this?
3999
4000        A basic example:
4001
4002        >>> obs = JournalObserver()
4003        >>> e = obs.getExploration()
4004        >>> obs.recordStart('A')
4005        >>> e.getExplorationStatus('A', 0)
4006        'unknown'
4007        >>> e.getExplorationStatus('A', 1)
4008        'exploring'
4009        >>> obs.recordStatus('A')
4010        >>> e.getExplorationStatus('A', 1)
4011        'explored'
4012        >>> obs.recordStatus('A', 'hypothesized')
4013        >>> e.getExplorationStatus('A', 1)
4014        'hypothesized'
4015
4016        An example of usage in journal format:
4017
4018        >>> obs = JournalObserver()
4019        >>> obs.observe('''
4020        ... # step 0
4021        ... S A  # step 1
4022        ... x right B left  # step 2
4023        ...   ...
4024        ... x right C left  # step 3
4025        ... t left  # back to B; step 4
4026        ...   o up
4027        ...   .  # now we think we've found all options
4028        ... x up D down  # step 5
4029        ... t down  # back to B again; step 6
4030        ... x down E up  # surprise extra option; step 7
4031        ... w  # step 8
4032        ...   . hypothesized  # explicit value
4033        ... t up  # auto-updates to 'explored'; step 9
4034        ... ''')
4035        >>> e = obs.getExploration()
4036        >>> len(e)
4037        10
4038        >>> e.getExplorationStatus('A', 1)
4039        'exploring'
4040        >>> e.getExplorationStatus('A', 2)
4041        'explored'
4042        >>> e.getExplorationStatus('B', 1)
4043        Traceback (most recent call last):
4044        ...
4045        exploration.core.MissingDecisionError...
4046        >>> e.getExplorationStatus(1, 1)  # the unknown node is created
4047        'unknown'
4048        >>> e.getExplorationStatus('B', 2)
4049        'exploring'
4050        >>> e.getExplorationStatus('B', 3)  # not 'explored' yet
4051        'exploring'
4052        >>> e.getExplorationStatus('B', 4)  # now explored
4053        'explored'
4054        >>> e.getExplorationStatus('B', 6)  # still explored
4055        'explored'
4056        >>> e.getExplorationStatus('E', 7)  # initial
4057        'exploring'
4058        >>> e.getExplorationStatus('E', 8)  # explicit
4059        'hypothesized'
4060        >>> e.getExplorationStatus('E', 9)  # auto-update on leave
4061        'explored'
4062        >>> g2 = e.getSituation(2).graph
4063        >>> g4 = e.getSituation(4).graph
4064        >>> g7 = e.getSituation(7).graph
4065        >>> g2.destinationsFrom('B')
4066        {'left': 0, 'right': 2}
4067        >>> g4.destinationsFrom('B')
4068        {'left': 0, 'right': 2, 'up': 3}
4069        >>> g7.destinationsFrom('B')
4070        {'left': 0, 'right': 2, 'up': 3, 'down': 4}
4071        """
4072        e = self.exploration
4073        dID = e.getSituation().graph.resolveDecision(decision)
4074        if dID in self.dontFinalize:
4075            self.dontFinalize.remove(dID)
4076        e.setExplorationStatus(decision, status)

Explicitly records that a particular decision has the specified exploration status (default 'explored' meaning we think we've seen everything there). This helps analysts look for unexpected connections.

Note that normally, exploration statuses will be updated automatically whenever a decision is first observed (status 'noticed'), first visited (status 'exploring') and first left behind (status 'explored'). However, using recordObservationIncomplete can prevent the automatic 'explored' update.

This method also removes a decision's dontFinalize entry, although it's probably no longer relevant in any case. TODO: Still this?

A basic example:

>>> obs = JournalObserver()
>>> e = obs.getExploration()
>>> obs.recordStart('A')
>>> e.getExplorationStatus('A', 0)
'unknown'
>>> e.getExplorationStatus('A', 1)
'exploring'
>>> obs.recordStatus('A')
>>> e.getExplorationStatus('A', 1)
'explored'
>>> obs.recordStatus('A', 'hypothesized')
>>> e.getExplorationStatus('A', 1)
'hypothesized'

An example of usage in journal format:

>>> obs = JournalObserver()
>>> obs.observe('''
... # step 0
... S A  # step 1
... x right B left  # step 2
...   ...
... x right C left  # step 3
... t left  # back to B; step 4
...   o up
...   .  # now we think we've found all options
... x up D down  # step 5
... t down  # back to B again; step 6
... x down E up  # surprise extra option; step 7
... w  # step 8
...   . hypothesized  # explicit value
... t up  # auto-updates to 'explored'; step 9
... ''')
>>> e = obs.getExploration()
>>> len(e)
10
>>> e.getExplorationStatus('A', 1)
'exploring'
>>> e.getExplorationStatus('A', 2)
'explored'
>>> e.getExplorationStatus('B', 1)
Traceback (most recent call last):
...
exploration.core.MissingDecisionError...
>>> e.getExplorationStatus(1, 1)  # the unknown node is created
'unknown'
>>> e.getExplorationStatus('B', 2)
'exploring'
>>> e.getExplorationStatus('B', 3)  # not 'explored' yet
'exploring'
>>> e.getExplorationStatus('B', 4)  # now explored
'explored'
>>> e.getExplorationStatus('B', 6)  # still explored
'explored'
>>> e.getExplorationStatus('E', 7)  # initial
'exploring'
>>> e.getExplorationStatus('E', 8)  # explicit
'hypothesized'
>>> e.getExplorationStatus('E', 9)  # auto-update on leave
'explored'
>>> g2 = e.getSituation(2).graph
>>> g4 = e.getSituation(4).graph
>>> g7 = e.getSituation(7).graph
>>> g2.destinationsFrom('B')
{'left': 0, 'right': 2}
>>> g4.destinationsFrom('B')
{'left': 0, 'right': 2, 'up': 3}
>>> g7.destinationsFrom('B')
{'left': 0, 'right': 2, 'up': 3, 'down': 4}
def autoFinalizeExplorationStatuses(self):
4078    def autoFinalizeExplorationStatuses(self):
4079        """
4080        Looks at the set of nodes that were active in the previous
4081        exploration step but which are no longer active in this one, and
4082        sets their exploration statuses to 'explored' to indicate that
4083        we believe we've already at least observed all of their outgoing
4084        transitions.
4085
4086        Skips finalization for any decisions in our `dontFinalize` set
4087        (see `recordObservationIncomplete`).
4088        """
4089        oldActive = self.exploration.getActiveDecisions(-2)
4090        newAcive = self.exploration.getActiveDecisions()
4091        for leftBehind in (oldActive - newAcive) - self.dontFinalize:
4092            self.exploration.setExplorationStatus(
4093                leftBehind,
4094                'explored'
4095            )

Looks at the set of nodes that were active in the previous exploration step but which are no longer active in this one, and sets their exploration statuses to 'explored' to indicate that we believe we've already at least observed all of their outgoing transitions.

Skips finalization for any decisions in our dontFinalize set (see recordObservationIncomplete).

def recordExplore( self, transition: Union[str, Tuple[str, List[bool]]], destination: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, reciprocal: Optional[str] = None, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active') -> None:
4097    def recordExplore(
4098        self,
4099        transition: base.AnyTransition,
4100        destination: Optional[base.AnyDecisionSpecifier] = None,
4101        reciprocal: Optional[base.Transition] = None,
4102        decisionType: base.DecisionType = 'active'
4103    ) -> None:
4104        """
4105        Records the exploration of a transition which leads to a
4106        specific destination (possibly with outcomes specified for
4107        challenges that are part of that transition's consequences). The
4108        name of the reciprocal transition may also be specified, as can
4109        a non-default decision type (see `base.DecisionType`). Creates
4110        the transition if it needs to.
4111
4112        Note that if the destination specifier has no zone or domain
4113        information, even if a decision with that name already exists, if
4114        the current decision is in a level-0 zone and the existing
4115        decision is not in the same zone, a new decision with that name
4116        in the current level-0 zone will be created (otherwise, it would
4117        be an error to use 'explore' to connect to an already-visited
4118        decision).
4119
4120        If no destination name is specified, the destination node must
4121        already exist and the name of the destination must not begin
4122        with '_u.' otherwise a `JournalParseError` will be generated.
4123
4124        Sets the current transition to the transition taken.
4125
4126        Calls `autoFinalizeExplorationStatuses` to upgrade exploration
4127        statuses for no-longer-active nodes to 'explored'.
4128
4129        In relative mode, this makes all the same changes to the graph,
4130        without adding a new exploration step, applying transition
4131        effects, or changing exploration statuses.
4132        """
4133        here = self.definiteDecisionTarget()
4134
4135        transitionName, outcomes = base.nameAndOutcomes(transition)
4136
4137        # Create transition if it doesn't already exist
4138        now = self.exploration.getSituation()
4139        graph = now.graph
4140        leadsTo = graph.getDestination(here, transitionName)
4141
4142        if isinstance(destination, str):
4143            destination = self.parseFormat.parseDecisionSpecifier(
4144                destination
4145            )
4146
4147        newDomain: Optional[base.Domain]
4148        newZone: Union[
4149            base.Zone,
4150            type[base.DefaultZone],
4151            None
4152        ] = base.DefaultZone
4153        newName: Optional[base.DecisionName]
4154
4155        # if a destination is specified, we need to check that it's not
4156        # an already-existing decision
4157        connectBack: bool = False  # are we connecting to a known decision?
4158        if destination is not None:
4159            # If it's not an ID, splice in current node info:
4160            if isinstance(destination, base.DecisionName):
4161                destination = base.DecisionSpecifier(None, None, destination)
4162            if isinstance(destination, base.DecisionSpecifier):
4163                destination = base.spliceDecisionSpecifiers(
4164                    destination,
4165                    self.decisionTargetSpecifier()
4166                )
4167            exists = graph.getDecision(destination)
4168            # if the specified decision doesn't exist; great. We'll
4169            # create it below
4170            if exists is not None:
4171                # If it does exist, we may have a problem. 'return' must
4172                # be used instead of 'explore' to connect to an existing
4173                # visited decision. But let's see if we really have a
4174                # conflict?
4175                otherZones = set(
4176                    z
4177                    for z in graph.zoneParents(exists)
4178                    if graph.zoneHierarchyLevel(z) == 0
4179                )
4180                currentZones = set(
4181                    z
4182                    for z in graph.zoneParents(here)
4183                    if graph.zoneHierarchyLevel(z) == 0
4184                )
4185                if (
4186                    len(otherZones & currentZones) != 0
4187                 or (
4188                        len(otherZones) == 0
4189                    and len(currentZones) == 0
4190                    )
4191                ):
4192                    if self.exploration.hasBeenVisited(exists):
4193                        # A decision by this name exists and shares at
4194                        # least one level-0 zone with the current
4195                        # decision. That means that 'return' should have
4196                        # been used.
4197                        raise JournalParseError(
4198                            f"Destiation {destination} is invalid"
4199                            f" because that decision has already been"
4200                            f" visited in the current zone. Use"
4201                            f" 'return' to record a new connection to"
4202                            f" an already-visisted decision."
4203                        )
4204                    else:
4205                        connectBack = True
4206                else:
4207                    connectBack = True
4208                # Otherwise, we can continue; the DefaultZone setting
4209                # already in place will prevail below
4210
4211        # Figure out domain & zone info for new destination
4212        if isinstance(destination, base.DecisionSpecifier):
4213            # Use current decision's domain by default
4214            if destination.domain is not None:
4215                newDomain = destination.domain
4216            else:
4217                newDomain = graph.domainFor(here)
4218
4219            # Use specified zone if there is one, else leave it as
4220            # DefaultZone to inherit zone(s) from the current decision.
4221            if destination.zone is not None:
4222                newZone = destination.zone
4223
4224            newName = destination.name
4225            # TODO: Some way to specify non-zone placement in explore?
4226
4227        elif isinstance(destination, base.DecisionID):
4228            if connectBack:
4229                newDomain = graph.domainFor(here)
4230                newZone = None
4231                newName = None
4232            else:
4233                raise JournalParseError(
4234                    f"You cannot use a decision ID when specifying a"
4235                    f" new name for an exploration destination (got:"
4236                    f" {repr(destination)})"
4237                )
4238
4239        elif isinstance(destination, base.DecisionName):
4240            newDomain = None
4241            newZone = base.DefaultZone
4242            newName = destination
4243
4244        else:  # must be None
4245            assert destination is None
4246            newDomain = None
4247            newZone = base.DefaultZone
4248            newName = None
4249
4250        if leadsTo is None:
4251            if newName is None and not connectBack:
4252                raise JournalParseError(
4253                    f"Transition {transition!r} at decision"
4254                    f" {graph.identityOf(here)} does not already exist,"
4255                    f" so a destination name must be provided."
4256                )
4257            else:
4258                graph.addUnexploredEdge(
4259                    here,
4260                    transitionName,
4261                    toDomain=newDomain  # None is the default anyways
4262                )
4263                # Zone info only added in next step
4264        elif newName is None:
4265            # TODO: Generalize this... ?
4266            currentName = graph.nameFor(leadsTo)
4267            if currentName.startswith('_u.'):
4268                raise JournalParseError(
4269                    f"Destination {graph.identityOf(leadsTo)} from"
4270                    f" decision {graph.identityOf(here)} via transition"
4271                    f" {transition!r} must be named when explored,"
4272                    f" because its current name is a placeholder."
4273                )
4274            else:
4275                newName = currentName
4276
4277        # TODO: Check for incompatible domain/zone in destination
4278        # specifier?
4279
4280        if self.inRelativeMode:
4281            if connectBack:  # connect to existing unconfirmed decision
4282                assert exists is not None
4283                graph.replaceUnconfirmed(
4284                    here,
4285                    transitionName,
4286                    exists,
4287                    reciprocal
4288                )  # we assume zones are already in place here
4289                self.exploration.setExplorationStatus(
4290                    exists,
4291                    'noticed',
4292                    upgradeOnly=True
4293                )
4294            else:  # connect to a new decision
4295                graph.replaceUnconfirmed(
4296                    here,
4297                    transitionName,
4298                    newName,
4299                    reciprocal,
4300                    placeInZone=newZone,
4301                    forceNew=True
4302                )
4303                destID = graph.destination(here, transitionName)
4304                self.exploration.setExplorationStatus(
4305                    destID,
4306                    'noticed',
4307                    upgradeOnly=True
4308                )
4309            self.context['decision'] = graph.destination(
4310                here,
4311                transitionName
4312            )
4313            self.context['transition'] = (here, transitionName)
4314        else:
4315            if connectBack:  # to a known but unvisited decision
4316                destID = self.exploration.explore(
4317                    (transitionName, outcomes),
4318                    exists,
4319                    reciprocal,
4320                    zone=newZone,
4321                    decisionType=decisionType
4322                )
4323            else:  # to an entirely new decision
4324                destID = self.exploration.explore(
4325                    (transitionName, outcomes),
4326                    newName,
4327                    reciprocal,
4328                    zone=newZone,
4329                    decisionType=decisionType
4330                )
4331            self.context['decision'] = destID
4332            self.context['transition'] = (here, transitionName)
4333            self.autoFinalizeExplorationStatuses()

Records the exploration of a transition which leads to a specific destination (possibly with outcomes specified for challenges that are part of that transition's consequences). The name of the reciprocal transition may also be specified, as can a non-default decision type (see base.DecisionType). Creates the transition if it needs to.

Note that if the destination specifier has no zone or domain information, even if a decision with that name already exists, if the current decision is in a level-0 zone and the existing decision is not in the same zone, a new decision with that name in the current level-0 zone will be created (otherwise, it would be an error to use 'explore' to connect to an already-visited decision).

If no destination name is specified, the destination node must already exist and the name of the destination must not begin with '_u.' otherwise a JournalParseError will be generated.

Sets the current transition to the transition taken.

Calls autoFinalizeExplorationStatuses to upgrade exploration statuses for no-longer-active nodes to 'explored'.

In relative mode, this makes all the same changes to the graph, without adding a new exploration step, applying transition effects, or changing exploration statuses.

def recordRetrace( self, transition: Union[str, Tuple[str, List[bool]]], decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active', isAction: Optional[bool] = None) -> None:
4335    def recordRetrace(
4336        self,
4337        transition: base.AnyTransition,
4338        decisionType: base.DecisionType = 'active',
4339        isAction: Optional[bool] = None
4340    ) -> None:
4341        """
4342        Records retracing a transition which leads to a known
4343        destination. A non-default decision type can be specified. If
4344        `isAction` is True or False, the transition must be (or must not
4345        be) an action (i.e., a transition whose destination is the same
4346        as its source). If `isAction` is left as `None` (the default)
4347        then either normal or action transitions can be retraced.
4348
4349        Sets the current transition to the transition taken.
4350
4351        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4352
4353        In relative mode, simply sets the current transition target to
4354        the transition taken and sets the current decision target to its
4355        destination (it does not apply transition effects).
4356        """
4357        here = self.definiteDecisionTarget()
4358
4359        transitionName, outcomes = base.nameAndOutcomes(transition)
4360
4361        graph = self.exploration.getSituation().graph
4362        destination = graph.getDestination(here, transitionName)
4363        if destination is None:
4364            valid = graph.destinationsListing(graph.destinationsFrom(here))
4365            raise JournalParseError(
4366                f"Cannot retrace transition {transitionName!r} from"
4367                f" decision {graph.identityOf(here)}: that transition"
4368                f" does not exist. Destinations available are:"
4369                f"\n{valid}"
4370            )
4371        if isAction is True and destination != here:
4372            raise JournalParseError(
4373                f"Cannot retrace transition {transitionName!r} from"
4374                f" decision {graph.identityOf(here)}: that transition"
4375                f" leads to {graph.identityOf(destination)} but you"
4376                f" specified that an existing action should be retraced,"
4377                f" not a normal transition. Use `recordAction` instead"
4378                f" to record a new action (including converting an"
4379                f" unconfirmed transition into an action). Leave"
4380                f" `isAction` unspeicfied or set it to `False` to"
4381                f" retrace a normal transition."
4382            )
4383        elif isAction is False and destination == here:
4384            raise JournalParseError(
4385                f"Cannot retrace transition {transitionName!r} from"
4386                f" decision {graph.identityOf(here)}: that transition"
4387                f" leads back to {graph.identityOf(destination)} but you"
4388                f" specified that an outgoing transition should be"
4389                f" retraced, not an action. Use `recordAction` instead"
4390                f" to record a new action (which must not have the same"
4391                f" name as any outgoing transition). Leave `isAction`"
4392                f" unspeicfied or set it to `True` to retrace an action."
4393            )
4394
4395        if not self.inRelativeMode:
4396            destID = self.exploration.retrace(
4397                (transitionName, outcomes),
4398                decisionType=decisionType
4399            )
4400            self.autoFinalizeExplorationStatuses()
4401        self.context['decision'] = destID
4402        self.context['transition'] = (here, transitionName)

Records retracing a transition which leads to a known destination. A non-default decision type can be specified. If isAction is True or False, the transition must be (or must not be) an action (i.e., a transition whose destination is the same as its source). If isAction is left as None (the default) then either normal or action transitions can be retraced.

Sets the current transition to the transition taken.

Calls autoFinalizeExplorationStatuses unless in relative mode.

In relative mode, simply sets the current transition target to the transition taken and sets the current decision target to its destination (it does not apply transition effects).

def recordAction( self, action: Union[str, Tuple[str, List[bool]]], decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active') -> None:
4404    def recordAction(
4405        self,
4406        action: base.AnyTransition,
4407        decisionType: base.DecisionType = 'active'
4408    ) -> None:
4409        """
4410        Records a new action taken at the current decision. A
4411        non-standard decision type may be specified. If a transition of
4412        that name already existed, it will be converted into an action
4413        assuming that its destination is unexplored and has no
4414        connections yet, and that its reciprocal also has no special
4415        properties yet. If those assumptions do not hold, a
4416        `JournalParseError` will be raised under the assumption that the
4417        name collision was an accident, not intentional, since the
4418        destination and reciprocal are deleted in the process of
4419        converting a normal transition into an action.
4420
4421        This cannot be used to re-triggger an existing action, use
4422        'retrace' for that.
4423
4424        In relative mode, the action is created (or the transition is
4425        converted into an action) but effects are not applied.
4426
4427        Although this does not usually change which decisions are
4428        active, it still calls `autoFinalizeExplorationStatuses` unless
4429        in relative mode.
4430
4431        Example:
4432
4433        >>> o = JournalObserver()
4434        >>> e = o.getExploration()
4435        >>> o.recordStart('start')
4436        >>> o.recordObserve('transition')
4437        >>> e.effectiveCapabilities()['capabilities']
4438        set()
4439        >>> o.recordObserveAction('action')
4440        >>> o.recordTransitionConsequence([base.effect(gain="capability")])
4441        >>> o.recordRetrace('action', isAction=True)
4442        >>> e.effectiveCapabilities()['capabilities']
4443        {'capability'}
4444        >>> o.recordAction('another') # add effects after...
4445        >>> effect = base.effect(lose="capability")
4446        >>> # This applies the effect and then adds it to the
4447        >>> # transition, since we already took the transition
4448        >>> o.recordAdditionalTransitionConsequence([effect])
4449        >>> e.effectiveCapabilities()['capabilities']
4450        set()
4451        >>> len(e)
4452        4
4453        >>> e.getActiveDecisions(0)
4454        set()
4455        >>> e.getActiveDecisions(1)
4456        {0}
4457        >>> e.getActiveDecisions(2)
4458        {0}
4459        >>> e.getActiveDecisions(3)
4460        {0}
4461        >>> e.getSituation(0).action
4462        ('start', 0, 0, 'main', None, None, None)
4463        >>> e.getSituation(1).action
4464        ('take', 'active', 0, ('action', []))
4465        >>> e.getSituation(2).action
4466        ('take', 'active', 0, ('another', []))
4467        """
4468        here = self.definiteDecisionTarget()
4469
4470        actionName, outcomes = base.nameAndOutcomes(action)
4471
4472        # Check if the transition already exists
4473        now = self.exploration.getSituation()
4474        graph = now.graph
4475        hereIdent = graph.identityOf(here)
4476        destinations = graph.destinationsFrom(here)
4477
4478        # A transition going somewhere else
4479        if actionName in destinations:
4480            if destinations[actionName] == here:
4481                raise JournalParseError(
4482                    f"Action {actionName!r} already exists as an action"
4483                    f" at decision {hereIdent!r}. Use 'retrace' to"
4484                    " re-activate an existing action."
4485                )
4486            else:
4487                destination = destinations[actionName]
4488                reciprocal = graph.getReciprocal(here, actionName)
4489                # To replace a transition with an action, the transition
4490                # may only have outgoing properties. Otherwise we assume
4491                # it's an error to name the action after a transition
4492                # which was intended to be a real transition.
4493                if (
4494                    graph.isConfirmed(destination)
4495                 or self.exploration.hasBeenVisited(destination)
4496                 or cast(int, graph.degree(destination)) > 2
4497                    # TODO: Fix MultiDigraph type stubs...
4498                ):
4499                    raise JournalParseError(
4500                        f"Action {actionName!r} has the same name as"
4501                        f" outgoing transition {actionName!r} at"
4502                        f" decision {hereIdent!r}. We cannot turn that"
4503                        f" transition into an action since its"
4504                        f" destination is already explored or has been"
4505                        f" connected to."
4506                    )
4507                if (
4508                    reciprocal is not None
4509                and graph.getTransitionProperties(
4510                        destination,
4511                        reciprocal
4512                    ) != {
4513                        'requirement': base.ReqNothing(),
4514                        'effects': [],
4515                        'tags': {},
4516                        'annotations': []
4517                    }
4518                ):
4519                    raise JournalParseError(
4520                        f"Action {actionName!r} has the same name as"
4521                        f" outgoing transition {actionName!r} at"
4522                        f" decision {hereIdent!r}. We cannot turn that"
4523                        f" transition into an action since its"
4524                        f" reciprocal has custom properties."
4525                    )
4526
4527                if (
4528                    graph.decisionAnnotations(destination) != []
4529                 or graph.decisionTags(destination) != {'unknown': 1}
4530                ):
4531                    raise JournalParseError(
4532                        f"Action {actionName!r} has the same name as"
4533                        f" outgoing transition {actionName!r} at"
4534                        f" decision {hereIdent!r}. We cannot turn that"
4535                        f" transition into an action since its"
4536                        f" destination has tags and/or annotations."
4537                    )
4538
4539                # If we get here, re-target the transition, and then
4540                # destroy the old destination along with the old
4541                # reciprocal edge.
4542                graph.retargetTransition(
4543                    here,
4544                    actionName,
4545                    here,
4546                    swapReciprocal=False
4547                )
4548                graph.removeDecision(destination)
4549
4550        # This will either take the existing action OR create it if
4551        # necessary
4552        if self.inRelativeMode:
4553            if actionName not in destinations:
4554                graph.addAction(here, actionName)
4555        else:
4556            destID = self.exploration.takeAction(
4557                (actionName, outcomes),
4558                fromDecision=here,
4559                decisionType=decisionType
4560            )
4561            self.autoFinalizeExplorationStatuses()
4562            self.context['decision'] = destID
4563        self.context['transition'] = (here, actionName)

Records a new action taken at the current decision. A non-standard decision type may be specified. If a transition of that name already existed, it will be converted into an action assuming that its destination is unexplored and has no connections yet, and that its reciprocal also has no special properties yet. If those assumptions do not hold, a JournalParseError will be raised under the assumption that the name collision was an accident, not intentional, since the destination and reciprocal are deleted in the process of converting a normal transition into an action.

This cannot be used to re-triggger an existing action, use 'retrace' for that.

In relative mode, the action is created (or the transition is converted into an action) but effects are not applied.

Although this does not usually change which decisions are active, it still calls autoFinalizeExplorationStatuses unless in relative mode.

Example:

>>> o = JournalObserver()
>>> e = o.getExploration()
>>> o.recordStart('start')
>>> o.recordObserve('transition')
>>> e.effectiveCapabilities()['capabilities']
set()
>>> o.recordObserveAction('action')
>>> o.recordTransitionConsequence([base.effect(gain="capability")])
>>> o.recordRetrace('action', isAction=True)
>>> e.effectiveCapabilities()['capabilities']
{'capability'}
>>> o.recordAction('another') # add effects after...
>>> effect = base.effect(lose="capability")
>>> # This applies the effect and then adds it to the
>>> # transition, since we already took the transition
>>> o.recordAdditionalTransitionConsequence([effect])
>>> e.effectiveCapabilities()['capabilities']
set()
>>> len(e)
4
>>> e.getActiveDecisions(0)
set()
>>> e.getActiveDecisions(1)
{0}
>>> e.getActiveDecisions(2)
{0}
>>> e.getActiveDecisions(3)
{0}
>>> e.getSituation(0).action
('start', 0, 0, 'main', None, None, None)
>>> e.getSituation(1).action
('take', 'active', 0, ('action', []))
>>> e.getSituation(2).action
('take', 'active', 0, ('another', []))
def recordReturn( self, transition: Union[str, Tuple[str, List[bool]]], destination: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, reciprocal: Optional[str] = None, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active') -> None:
4565    def recordReturn(
4566        self,
4567        transition: base.AnyTransition,
4568        destination: Optional[base.AnyDecisionSpecifier] = None,
4569        reciprocal: Optional[base.Transition] = None,
4570        decisionType: base.DecisionType = 'active'
4571    ) -> None:
4572        """
4573        Records an exploration which leads back to a
4574        previously-encountered decision. If a reciprocal is specified,
4575        we connect to that transition as our reciprocal (it must have
4576        led to an unknown area or not have existed) or if not, we make a
4577        new connection with an automatic reciprocal name.
4578        A non-standard decision type may be specified.
4579
4580        If no destination is specified, then the destination of the
4581        transition must already exist.
4582
4583        If the specified transition does not exist, it will be created.
4584
4585        Sets the current transition to the transition taken.
4586
4587        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4588
4589        In relative mode, does the same stuff but doesn't apply any
4590        transition effects.
4591        """
4592        here = self.definiteDecisionTarget()
4593        now = self.exploration.getSituation()
4594        graph = now.graph
4595
4596        transitionName, outcomes = base.nameAndOutcomes(transition)
4597
4598        if destination is None:
4599            destination = graph.getDestination(here, transitionName)
4600            if destination is None:
4601                raise JournalParseError(
4602                    f"Cannot 'return' across transition"
4603                    f" {transitionName!r} from decision"
4604                    f" {graph.identityOf(here)} without specifying a"
4605                    f" destination, because that transition does not"
4606                    f" already have a destination."
4607                )
4608
4609        if isinstance(destination, str):
4610            destination = self.parseFormat.parseDecisionSpecifier(
4611                destination
4612            )
4613
4614        # If we started with a name or some other kind of decision
4615        # specifier, replace missing domain and/or zone info with info
4616        # from the current decision.
4617        if isinstance(destination, base.DecisionSpecifier):
4618            destination = base.spliceDecisionSpecifiers(
4619                destination,
4620                self.decisionTargetSpecifier()
4621            )
4622
4623        # Add an unexplored edge just before doing the return if the
4624        # named transition didn't already exist.
4625        if graph.getDestination(here, transitionName) is None:
4626            graph.addUnexploredEdge(here, transitionName)
4627
4628        # Works differently in relative mode
4629        if self.inRelativeMode:
4630            graph.replaceUnconfirmed(
4631                here,
4632                transitionName,
4633                destination,
4634                reciprocal
4635            )
4636            self.context['decision'] = graph.resolveDecision(destination)
4637            self.context['transition'] = (here, transitionName)
4638        else:
4639            destID = self.exploration.returnTo(
4640                (transitionName, outcomes),
4641                destination,
4642                reciprocal,
4643                decisionType=decisionType
4644            )
4645            self.autoFinalizeExplorationStatuses()
4646            self.context['decision'] = destID
4647            self.context['transition'] = (here, transitionName)

Records an exploration which leads back to a previously-encountered decision. If a reciprocal is specified, we connect to that transition as our reciprocal (it must have led to an unknown area or not have existed) or if not, we make a new connection with an automatic reciprocal name. A non-standard decision type may be specified.

If no destination is specified, then the destination of the transition must already exist.

If the specified transition does not exist, it will be created.

Sets the current transition to the transition taken.

Calls autoFinalizeExplorationStatuses unless in relative mode.

In relative mode, does the same stuff but doesn't apply any transition effects.

def recordWarp( self, destination: Union[int, exploration.base.DecisionSpecifier, str], decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active') -> None:
4649    def recordWarp(
4650        self,
4651        destination: base.AnyDecisionSpecifier,
4652        decisionType: base.DecisionType = 'active'
4653    ) -> None:
4654        """
4655        Records a warp to a specific destination without creating a
4656        transition. If the destination did not exist, it will be
4657        created (but only if a `base.DecisionName` or
4658        `base.DecisionSpecifier` was supplied; a destination cannot be
4659        created based on a non-existent `base.DecisionID`).
4660        A non-standard decision type may be specified.
4661
4662        If the destination already exists its zones won't be changed.
4663        However, if the destination gets created, it will be in the same
4664        domain and added to the same zones as the previous position, or
4665        to whichever zone was specified as the zone component of a
4666        `base.DecisionSpecifier`, if any.
4667
4668        Sets the current transition to `None`.
4669
4670        In relative mode, simply updates the current target decision and
4671        sets the current target transition to `None`. It will still
4672        create the destination if necessary, possibly putting it in a
4673        zone. In relative mode, the destination's exploration status is
4674        set to "noticed" (and no exploration step is created), while in
4675        normal mode, the exploration status is set to 'unknown' in the
4676        original current step, and then a new step is added which will
4677        set the status to 'exploring'.
4678
4679        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4680        """
4681        now = self.exploration.getSituation()
4682        graph = now.graph
4683
4684        if isinstance(destination, str):
4685            destination = self.parseFormat.parseDecisionSpecifier(
4686                destination
4687            )
4688
4689        destID = graph.getDecision(destination)
4690
4691        newZone: Union[
4692            base.Zone,
4693            type[base.DefaultZone],
4694            None
4695        ] = base.DefaultZone
4696        here = self.currentDecisionTarget()
4697        newDomain: Optional[base.Domain] = None
4698        if here is not None:
4699            newDomain = graph.domainFor(here)
4700        if self.inRelativeMode:  # create the decision if it didn't exist
4701            if destID not in graph:  # including if it's None
4702                if isinstance(destination, base.DecisionID):
4703                    raise JournalParseError(
4704                        f"Cannot go to decision {destination} because that"
4705                        f" decision ID does not exist, and we cannot create"
4706                        f" a new decision based only on a decision ID. Use"
4707                        f" a DecisionSpecifier or DecisionName to go to a"
4708                        f" new decision that needs to be created."
4709                    )
4710                elif isinstance(destination, base.DecisionName):
4711                    newName = destination
4712                    newZone = base.DefaultZone
4713                elif isinstance(destination, base.DecisionSpecifier):
4714                    specDomain, newZone, newName = destination
4715                    if specDomain is not None:
4716                        newDomain = specDomain
4717                else:
4718                    raise JournalParseError(
4719                        f"Invalid decision specifier: {repr(destination)}."
4720                        f" The destination must be a decision ID, a"
4721                        f" decision name, or a decision specifier."
4722                    )
4723                destID = graph.addDecision(newName, domain=newDomain)
4724                if newZone is base.DefaultZone:
4725                    ctxDecision = self.context['decision']
4726                    if ctxDecision is not None:
4727                        for zp in graph.zoneParents(ctxDecision):
4728                            graph.addDecisionToZone(destID, zp)
4729                elif newZone is not None:
4730                    graph.addDecisionToZone(destID, newZone)
4731                    # TODO: If this zone is new create it & add it to
4732                    # parent zones of old level-0 zone(s)?
4733
4734                base.setExplorationStatus(
4735                    now,
4736                    destID,
4737                    'noticed',
4738                    upgradeOnly=True
4739                )
4740                # TODO: Some way to specify 'hypothesized' here instead?
4741
4742        else:
4743            # in normal mode, 'DiscreteExploration.warp' takes care of
4744            # creating the decision if needed
4745            whichFocus = None
4746            if self.context['focus'] is not None:
4747                whichFocus = (
4748                    self.context['context'],
4749                    self.context['domain'],
4750                    self.context['focus']
4751                )
4752            if destination is None:
4753                destination = destID
4754
4755            if isinstance(destination, base.DecisionSpecifier):
4756                newZone = destination.zone
4757                if destination.domain is not None:
4758                    newDomain = destination.domain
4759            else:
4760                newZone = base.DefaultZone
4761
4762            destID = self.exploration.warp(
4763                destination,
4764                domain=newDomain,
4765                zone=newZone,
4766                whichFocus=whichFocus,
4767                inCommon=self.context['context'] == 'common',
4768                decisionType=decisionType
4769            )
4770            self.autoFinalizeExplorationStatuses()
4771
4772        self.context['decision'] = destID
4773        self.context['transition'] = None

Records a warp to a specific destination without creating a transition. If the destination did not exist, it will be created (but only if a base.DecisionName or base.DecisionSpecifier was supplied; a destination cannot be created based on a non-existent base.DecisionID). A non-standard decision type may be specified.

If the destination already exists its zones won't be changed. However, if the destination gets created, it will be in the same domain and added to the same zones as the previous position, or to whichever zone was specified as the zone component of a base.DecisionSpecifier, if any.

Sets the current transition to None.

In relative mode, simply updates the current target decision and sets the current target transition to None. It will still create the destination if necessary, possibly putting it in a zone. In relative mode, the destination's exploration status is set to "noticed" (and no exploration step is created), while in normal mode, the exploration status is set to 'unknown' in the original current step, and then a new step is added which will set the status to 'exploring'.

Calls autoFinalizeExplorationStatuses unless in relative mode.

def recordWait( self, decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active') -> None:
4775    def recordWait(
4776        self,
4777        decisionType: base.DecisionType = 'active'
4778    ) -> None:
4779        """
4780        Records a wait step. Does not modify the current transition.
4781        A non-standard decision type may be specified.
4782
4783        Raises a `JournalParseError` in relative mode, since it wouldn't
4784        have any effect.
4785        """
4786        if self.inRelativeMode:
4787            raise JournalParseError("Can't wait in relative mode.")
4788        else:
4789            self.exploration.wait(decisionType=decisionType)

Records a wait step. Does not modify the current transition. A non-standard decision type may be specified.

Raises a JournalParseError in relative mode, since it wouldn't have any effect.

def recordObserveEnding(self, name: str) -> None:
4791    def recordObserveEnding(self, name: base.DecisionName) -> None:
4792        """
4793        Records the observation of an action which warps to an ending,
4794        although unlike `recordEnd` we don't use that action yet. This
4795        does NOT update the current decision, although it sets the
4796        current transition to the action it creates.
4797
4798        The action created has the same name as the ending it warps to.
4799
4800        Note that normally, we just warp to endings, so there's no need
4801        to use `recordObserveEnding`. But if there's a player-controlled
4802        option to end the game at a particular node that is noticed
4803        before it's actually taken, this is the right thing to do.
4804
4805        We set up player-initiated ending transitions as actions with a
4806        goto rather than usual transitions because endings exist in a
4807        separate domain, and are active simultaneously with normal
4808        decisions.
4809        """
4810        graph = self.exploration.getSituation().graph
4811        here = self.definiteDecisionTarget()
4812        # Add the ending decision or grab the ID of the existing ending
4813        eID = graph.endingID(name)
4814        # Create action & add goto consequence
4815        graph.addAction(here, name)
4816        graph.setConsequence(here, name, [base.effect(goto=eID)])
4817        # Set the exploration status
4818        self.exploration.setExplorationStatus(
4819            eID,
4820            'noticed',
4821            upgradeOnly=True
4822        )
4823        self.context['transition'] = (here, name)
4824        # TODO: Prevent things like adding unexplored nodes to the
4825        # an ending...

Records the observation of an action which warps to an ending, although unlike recordEnd we don't use that action yet. This does NOT update the current decision, although it sets the current transition to the action it creates.

The action created has the same name as the ending it warps to.

Note that normally, we just warp to endings, so there's no need to use recordObserveEnding. But if there's a player-controlled option to end the game at a particular node that is noticed before it's actually taken, this is the right thing to do.

We set up player-initiated ending transitions as actions with a goto rather than usual transitions because endings exist in a separate domain, and are active simultaneously with normal decisions.

def recordEnd( self, name: str, voluntary: bool = False, decisionType: Optional[Literal['pending', 'active', 'unintended', 'imposed', 'consequence']] = None) -> None:
4827    def recordEnd(
4828        self,
4829        name: base.DecisionName,
4830        voluntary: bool = False,
4831        decisionType: Optional[base.DecisionType] = None
4832    ) -> None:
4833        """
4834        Records an ending. If `voluntary` is `False` (the default) then
4835        this becomes a warp that activates the specified ending (which
4836        is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave
4837        the current decision).
4838
4839        If `voluntary` is `True` then we also record an action with a
4840        'goto' effect that activates the specified ending, and record an
4841        exploration step that takes that action, instead of just a warp
4842        (`recordObserveEnding` would set up such an action without
4843        taking it).
4844
4845        The specified ending decision is created if it didn't already
4846        exist. If `voluntary` is True and an action that warps to the
4847        specified ending already exists with the correct name, we will
4848        simply take that action.
4849
4850        If it created an action, it sets the current transition to the
4851        action that warps to the ending. Endings are not added to zones;
4852        otherwise it sets the current transition to None.
4853
4854        In relative mode, an ending is still added, possibly with an
4855        action that warps to it, and the current decision is set to that
4856        ending node, but the transition doesn't actually get taken.
4857
4858        If not in relative mode, sets the exploration status of the
4859        current decision to `explored` if it wasn't in the
4860        `dontFinalize` set, even though we do not deactivate that
4861        transition.
4862
4863        When `voluntary` is not set, the decision type for the warp will
4864        be 'imposed', otherwise it will be 'active'. However, if an
4865        explicit `decisionType` is specified, that will override these
4866        defaults.
4867        """
4868        graph = self.exploration.getSituation().graph
4869        here = self.definiteDecisionTarget()
4870
4871        # Add our warping action if we need to
4872        if voluntary:
4873            # If voluntary, check for an existing warp action and set
4874            # one up if we don't have one.
4875            aDest = graph.getDestination(here, name)
4876            eID = graph.getDecision(
4877                base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name)
4878            )
4879            if aDest is None:
4880                # Okay we can just create the action
4881                self.recordObserveEnding(name)
4882                # else check if the existing transition is an action
4883                # that warps to the correct ending already
4884            elif (
4885                aDest != here
4886             or eID is None
4887             or not any(
4888                    c == base.effect(goto=eID)
4889                    for c in graph.getConsequence(here, name)
4890                )
4891            ):
4892                raise JournalParseError(
4893                    f"Attempting to add voluntary ending {name!r} at"
4894                    f" decision {graph.identityOf(here)} but that"
4895                    f" decision already has an action with that name"
4896                    f" and it's not set up to warp to that ending"
4897                    f" already."
4898                )
4899
4900        # Grab ending ID (creates the decision if necessary)
4901        eID = graph.endingID(name)
4902
4903        # Update our context variables
4904        self.context['decision'] = eID
4905        if voluntary:
4906            self.context['transition'] = (here, name)
4907        else:
4908            self.context['transition'] = None
4909
4910        # Update exploration status in relative mode, or possibly take
4911        # action in normal mode
4912        if self.inRelativeMode:
4913            self.exploration.setExplorationStatus(
4914                eID,
4915                "noticed",
4916                upgradeOnly=True
4917            )
4918        else:
4919            # Either take the action we added above, or just warp
4920            if decisionType is None:
4921                decisionType = 'active' if voluntary else 'imposed'
4922
4923            if voluntary:
4924                # Taking the action warps us to the ending
4925                self.exploration.takeAction(
4926                    name,
4927                    decisionType=decisionType
4928                )
4929            else:
4930                # We'll use a warp to get there
4931                self.exploration.warp(
4932                    base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name),
4933                    zone=None,
4934                    decisionType=decisionType
4935                )
4936                if (
4937                    here not in self.dontFinalize
4938                and (
4939                        self.exploration.getExplorationStatus(here)
4940                     == "exploring"
4941                    )
4942                ):
4943                    self.exploration.setExplorationStatus(here, "explored")
4944        # TODO: Prevent things like adding unexplored nodes to the
4945        # ending...

Records an ending. If voluntary is False (the default) then this becomes a warp that activates the specified ending (which is in the core.ENDINGS_DOMAIN domain, so that doesn't leave the current decision).

If voluntary is True then we also record an action with a 'goto' effect that activates the specified ending, and record an exploration step that takes that action, instead of just a warp (recordObserveEnding would set up such an action without taking it).

The specified ending decision is created if it didn't already exist. If voluntary is True and an action that warps to the specified ending already exists with the correct name, we will simply take that action.

If it created an action, it sets the current transition to the action that warps to the ending. Endings are not added to zones; otherwise it sets the current transition to None.

In relative mode, an ending is still added, possibly with an action that warps to it, and the current decision is set to that ending node, but the transition doesn't actually get taken.

If not in relative mode, sets the exploration status of the current decision to explored if it wasn't in the dontFinalize set, even though we do not deactivate that transition.

When voluntary is not set, the decision type for the warp will be 'imposed', otherwise it will be 'active'. However, if an explicit decisionType is specified, that will override these defaults.

def recordMechanism( self, where: Union[int, exploration.base.DecisionSpecifier, str, NoneType], name: str, startingState: str = 'off') -> None:
4947    def recordMechanism(
4948        self,
4949        where: Optional[base.AnyDecisionSpecifier],
4950        name: base.MechanismName,
4951        startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE
4952    ) -> None:
4953        """
4954        Records the existence of a mechanism at the specified decision
4955        with the specified starting state (or the default starting
4956        state). Set `where` to `None` to set up a global mechanism that's
4957        not tied to any particular decision.
4958        """
4959        graph = self.exploration.getSituation().graph
4960        # TODO: a way to set up global mechanisms
4961        newID = graph.addMechanism(name, where)
4962        if startingState != base.DEFAULT_MECHANISM_STATE:
4963            self.exploration.setMechanismStateNow(newID, startingState)

Records the existence of a mechanism at the specified decision with the specified starting state (or the default starting state). Set where to None to set up a global mechanism that's not tied to any particular decision.

def recordRequirement(self, req: Union[exploration.base.Requirement, str]) -> None:
4965    def recordRequirement(self, req: Union[base.Requirement, str]) -> None:
4966        """
4967        Records a requirement observed on the most recently
4968        defined/taken transition. If a string is given,
4969        `ParseFormat.parseRequirement` will be used to parse it.
4970        """
4971        if isinstance(req, str):
4972            req = self.parseFormat.parseRequirement(req)
4973        target = self.currentTransitionTarget()
4974        if target is None:
4975            raise JournalParseError(
4976                "Can't set a requirement because there is no current"
4977                " transition."
4978            )
4979        graph = self.exploration.getSituation().graph
4980        graph.setTransitionRequirement(
4981            *target,
4982            req
4983        )

Records a requirement observed on the most recently defined/taken transition. If a string is given, ParseFormat.parseRequirement will be used to parse it.

def recordReciprocalRequirement(self, req: Union[exploration.base.Requirement, str]) -> None:
4985    def recordReciprocalRequirement(
4986        self,
4987        req: Union[base.Requirement, str]
4988    ) -> None:
4989        """
4990        Records a requirement observed on the reciprocal of the most
4991        recently defined/taken transition. If a string is given,
4992        `ParseFormat.parseRequirement` will be used to parse it.
4993        """
4994        if isinstance(req, str):
4995            req = self.parseFormat.parseRequirement(req)
4996        target = self.currentReciprocalTarget()
4997        if target is None:
4998            raise JournalParseError(
4999                "Can't set a reciprocal requirement because there is no"
5000                " current transition or it doesn't have a reciprocal."
5001            )
5002        graph = self.exploration.getSituation().graph
5003        graph.setTransitionRequirement(*target, req)

Records a requirement observed on the reciprocal of the most recently defined/taken transition. If a string is given, ParseFormat.parseRequirement will be used to parse it.

def recordTransitionConsequence( self, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]) -> None:
5005    def recordTransitionConsequence(
5006        self,
5007        consequence: base.Consequence
5008    ) -> None:
5009        """
5010        Records a transition consequence, which gets added to any
5011        existing consequences of the currently-relevant transition (the
5012        most-recently created or taken transition). A `JournalParseError`
5013        will be raised if there is no current transition.
5014        """
5015        target = self.currentTransitionTarget()
5016        if target is None:
5017            raise JournalParseError(
5018                "Cannot apply a consequence because there is no current"
5019                " transition."
5020            )
5021
5022        now = self.exploration.getSituation()
5023        now.graph.addConsequence(*target, consequence)

Records a transition consequence, which gets added to any existing consequences of the currently-relevant transition (the most-recently created or taken transition). A JournalParseError will be raised if there is no current transition.

def recordReciprocalConsequence( self, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]]) -> None:
5025    def recordReciprocalConsequence(
5026        self,
5027        consequence: base.Consequence
5028    ) -> None:
5029        """
5030        Like `recordTransitionConsequence` but applies the effect to the
5031        reciprocal of the current transition. Will cause a
5032        `JournalParseError` if the current transition has no reciprocal
5033        (e.g., it's an ending transition).
5034        """
5035        target = self.currentReciprocalTarget()
5036        if target is None:
5037            raise JournalParseError(
5038                "Cannot apply a reciprocal effect because there is no"
5039                " current transition, or it doesn't have a reciprocal."
5040            )
5041
5042        now = self.exploration.getSituation()
5043        now.graph.addConsequence(*target, consequence)

Like recordTransitionConsequence but applies the effect to the reciprocal of the current transition. Will cause a JournalParseError if the current transition has no reciprocal (e.g., it's an ending transition).

def recordAdditionalTransitionConsequence( self, consequence: List[Union[exploration.base.Challenge, exploration.base.Effect, exploration.base.Condition]], hideEffects: bool = True) -> None:
5045    def recordAdditionalTransitionConsequence(
5046        self,
5047        consequence: base.Consequence,
5048        hideEffects: bool = True
5049    ) -> None:
5050        """
5051        Records the addition of a new consequence to the current
5052        relevant transition, while also triggering the effects of that
5053        consequence (but not the other effects of that transition, which
5054        we presume have just been applied already).
5055
5056        By default each effect added this way automatically gets the
5057        "hidden" property added to it, because the assumption is if it
5058        were a foreseeable effect, you would have added it to the
5059        transition before taking it. If you set `hideEffects` to
5060        `False`, this won't be done.
5061
5062        This modifies the current state but does not add a step to the
5063        exploration. It does NOT call `autoFinalizeExplorationStatuses`,
5064        which means that if a 'bounce' or 'goto' effect ends up making
5065        one or more decisions no-longer-active, they do NOT get their
5066        exploration statuses upgraded to 'explored'.
5067        """
5068        # Receive begin/end indices from `addConsequence` and send them
5069        # to `applyTransitionConsequence` to limit which # parts of the
5070        # expanded consequence are actually applied.
5071        currentTransition = self.currentTransitionTarget()
5072        if currentTransition is None:
5073            consRepr = self.parseFormat.unparseConsequence(consequence)
5074            raise JournalParseError(
5075                f"Can't apply an additional consequence to a transition"
5076                f" when there is no current transition. Got"
5077                f" consequence:\n{consRepr}"
5078            )
5079
5080        if hideEffects:
5081            for (index, item) in base.walkParts(consequence):
5082                if isinstance(item, dict) and 'value' in item:
5083                    assert 'hidden' in item
5084                    item = cast(base.Effect, item)
5085                    item['hidden'] = True
5086
5087        now = self.exploration.getSituation()
5088        begin, end = now.graph.addConsequence(
5089            *currentTransition,
5090            consequence
5091        )
5092        self.exploration.applyTransitionConsequence(
5093            *currentTransition,
5094            moveWhich=self.context['focus'],
5095            policy="specified",
5096            fromIndex=begin,
5097            toIndex=end
5098        )
5099        # This tracks trigger counts and obeys
5100        # charges/delays, unlike
5101        # applyExtraneousConsequence, but some effects
5102        # like 'bounce' still can't be properly applied

Records the addition of a new consequence to the current relevant transition, while also triggering the effects of that consequence (but not the other effects of that transition, which we presume have just been applied already).

By default each effect added this way automatically gets the "hidden" property added to it, because the assumption is if it were a foreseeable effect, you would have added it to the transition before taking it. If you set hideEffects to False, this won't be done.

This modifies the current state but does not add a step to the exploration. It does NOT call autoFinalizeExplorationStatuses, which means that if a 'bounce' or 'goto' effect ends up making one or more decisions no-longer-active, they do NOT get their exploration statuses upgraded to 'explored'.

def recordTagStep( self, tag: str, value: 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:
5104    def recordTagStep(
5105        self,
5106        tag: base.Tag,
5107        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5108    ) -> None:
5109        """
5110        Records a tag to be applied to the current exploration step.
5111        """
5112        self.exploration.tagStep(tag, value)

Records a tag to be applied to the current exploration step.

def recordTagDecision( self, tag: str, value: 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:
5114    def recordTagDecision(
5115        self,
5116        tag: base.Tag,
5117        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5118    ) -> None:
5119        """
5120        Records a tag to be applied to the current decision.
5121        """
5122        now = self.exploration.getSituation()
5123        now.graph.tagDecision(
5124            self.definiteDecisionTarget(),
5125            tag,
5126            value
5127        )

Records a tag to be applied to the current decision.

def recordTagTranstion( self, tag: str, value: 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:
5129    def recordTagTranstion(
5130        self,
5131        tag: base.Tag,
5132        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5133    ) -> None:
5134        """
5135        Records a tag to be applied to the most-recently-defined or
5136        -taken transition.
5137        """
5138        target = self.currentTransitionTarget()
5139        if target is None:
5140            raise JournalParseError(
5141                "Cannot tag a transition because there is no current"
5142                " transition."
5143            )
5144
5145        now = self.exploration.getSituation()
5146        now.graph.tagTransition(*target, tag, value)

Records a tag to be applied to the most-recently-defined or -taken transition.

def recordTagReciprocal( self, tag: str, value: 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:
5148    def recordTagReciprocal(
5149        self,
5150        tag: base.Tag,
5151        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5152    ) -> None:
5153        """
5154        Records a tag to be applied to the reciprocal of the
5155        most-recently-defined or -taken transition.
5156        """
5157        target = self.currentReciprocalTarget()
5158        if target is None:
5159            raise JournalParseError(
5160                "Cannot tag a transition because there is no current"
5161                " transition."
5162            )
5163
5164        now = self.exploration.getSituation()
5165        now.graph.tagTransition(*target, tag, value)

Records a tag to be applied to the reciprocal of the most-recently-defined or -taken transition.

def currentZoneAtLevel(self, level: int) -> str:
5167    def currentZoneAtLevel(self, level: int) -> base.Zone:
5168        """
5169        Returns a zone in the current graph that applies to the current
5170        decision which is at the specified hierarchy level. If there is
5171        no such zone, raises a `JournalParseError`. If there are
5172        multiple such zones, returns the zone which includes the fewest
5173        decisions, breaking ties alphabetically by zone name.
5174        """
5175        here = self.definiteDecisionTarget()
5176        graph = self.exploration.getSituation().graph
5177        ancestors = graph.zoneAncestors(here)
5178        candidates = [
5179            ancestor
5180            for ancestor in ancestors
5181            if graph.zoneHierarchyLevel(ancestor) == level
5182        ]
5183        if len(candidates) == 0:
5184            raise JournalParseError(
5185                (
5186                    f"Cannot find any level-{level} zones for the"
5187                    f" current decision {graph.identityOf(here)}. That"
5188                    f" decision is"
5189                ) + (
5190                    " in the following zones:"
5191                  + '\n'.join(
5192                        f"  level {graph.zoneHierarchyLevel(z)}: {z!r}"
5193                        for z in ancestors
5194                    )
5195                ) if len(ancestors) > 0 else (
5196                    " not in any zones."
5197                )
5198            )
5199        candidates.sort(
5200            key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone)
5201        )
5202        return candidates[0]

Returns a zone in the current graph that applies to the current decision which is at the specified hierarchy level. If there is no such zone, raises a JournalParseError. If there are multiple such zones, returns the zone which includes the fewest decisions, breaking ties alphabetically by zone name.

def recordTagZone( self, level: int, tag: str, value: 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:
5204    def recordTagZone(
5205        self,
5206        level: int,
5207        tag: base.Tag,
5208        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5209    ) -> None:
5210        """
5211        Records a tag to be applied to one of the zones that the current
5212        decision is in, at a specific hierarchy level. There must be at
5213        least one zone ancestor of the current decision at that hierarchy
5214        level; if there are multiple then the tag is applied to the
5215        smallest one, breaking ties by alphabetical order.
5216        """
5217        applyTo = self.currentZoneAtLevel(level)
5218        self.exploration.getSituation().graph.tagZone(applyTo, tag, value)

Records a tag to be applied to one of the zones that the current decision is in, at a specific hierarchy level. There must be at least one zone ancestor of the current decision at that hierarchy level; if there are multiple then the tag is applied to the smallest one, breaking ties by alphabetical order.

def recordAnnotateStep(self, *annotations: str) -> None:
5220    def recordAnnotateStep(
5221        self,
5222        *annotations: base.Annotation
5223    ) -> None:
5224        """
5225        Records annotations to be applied to the current exploration
5226        step.
5227        """
5228        self.exploration.annotateStep(annotations)
5229        pf = self.parseFormat
5230        now = self.exploration.getSituation()
5231        for a in annotations:
5232            if a.startswith("at:"):
5233                expects = pf.parseDecisionSpecifier(a[3:])
5234                if isinstance(expects, base.DecisionSpecifier):
5235                    if expects.domain is None and expects.zone is None:
5236                        expects = base.spliceDecisionSpecifiers(
5237                            expects,
5238                            self.decisionTargetSpecifier()
5239                        )
5240                eID = now.graph.getDecision(expects)
5241                primaryNow: Optional[base.DecisionID]
5242                if self.inRelativeMode:
5243                    primaryNow = self.definiteDecisionTarget()
5244                else:
5245                    primaryNow = now.state['primaryDecision']
5246                if eID is None:
5247                    self.warn(
5248                        f"'at' annotation expects position {expects!r}"
5249                        f" but that's not a valid decision specifier in"
5250                        f" the current graph."
5251                    )
5252                elif eID != primaryNow:
5253                    self.warn(
5254                        f"'at' annotation expects position {expects!r}"
5255                        f" which is decision"
5256                        f" {now.graph.identityOf(eID)}, but the current"
5257                        f" primary decision is"
5258                        f" {now.graph.identityOf(primaryNow)}"
5259                    )
5260            elif a.startswith("active:"):
5261                expects = pf.parseDecisionSpecifier(a[3:])
5262                eID = now.graph.getDecision(expects)
5263                atNow = base.combinedDecisionSet(now.state)
5264                if eID is None:
5265                    self.warn(
5266                        f"'active' annotation expects decision {expects!r}"
5267                        f" but that's not a valid decision specifier in"
5268                        f" the current graph."
5269                    )
5270                elif eID not in atNow:
5271                    self.warn(
5272                        f"'active' annotation expects decision {expects!r}"
5273                        f" which is {now.graph.identityOf(eID)}, but"
5274                        f" the current active position(s) is/are:"
5275                        f"\n{now.graph.namesListing(atNow)}"
5276                    )
5277            elif a.startswith("has:"):
5278                ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0]
5279                if (
5280                    isinstance(ea, tuple)
5281                and len(ea) == 2
5282                and isinstance(ea[0], base.Token)
5283                and isinstance(ea[1], base.TokenCount)
5284                ):
5285                    countNow = base.combinedTokenCount(now.state, ea[0])
5286                    if countNow != ea[1]:
5287                        self.warn(
5288                            f"'has' annotation expects {ea[1]} {ea[0]!r}"
5289                            f" token(s) but the current state has"
5290                            f" {countNow} of them."
5291                        )
5292                else:
5293                    self.warn(
5294                        f"'has' annotation expects tokens {a[4:]!r} but"
5295                        f" that's not a (token, count) pair."
5296                    )
5297            elif a.startswith("level:"):
5298                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5299                if (
5300                    isinstance(ea, tuple)
5301                and len(ea) == 3
5302                and ea[0] == 'skill'
5303                and isinstance(ea[1], base.Skill)
5304                and isinstance(ea[2], base.Level)
5305                ):
5306                    levelNow = base.getSkillLevel(now.state, ea[1])
5307                    if levelNow != ea[2]:
5308                        self.warn(
5309                            f"'level' annotation expects skill {ea[1]!r}"
5310                            f" to be at level {ea[2]} but the current"
5311                            f" level for that skill is {levelNow}."
5312                        )
5313                else:
5314                    self.warn(
5315                        f"'level' annotation expects skill {a[6:]!r} but"
5316                        f" that's not a (skill, level) pair."
5317                    )
5318            elif a.startswith("can:"):
5319                try:
5320                    req = pf.parseRequirement(a[4:])
5321                except parsing.ParseError:
5322                    self.warn(
5323                        f"'can' annotation expects requirement {a[4:]!r}"
5324                        f" but that's not parsable as a requirement."
5325                    )
5326                    req = None
5327                if req is not None:
5328                    ctx = base.genericContextForSituation(now)
5329                    if not req.satisfied(ctx):
5330                        self.warn(
5331                            f"'can' annotation expects requirement"
5332                            f" {req!r} to be satisfied but it's not in"
5333                            f" the current situation."
5334                        )
5335            elif a.startswith("state:"):
5336                ctx = base.genericContextForSituation(
5337                    now
5338                )
5339                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5340                if (
5341                    isinstance(ea, tuple)
5342                and len(ea) == 2
5343                and isinstance(ea[0], tuple)
5344                and len(ea[0]) == 4
5345                and (ea[0][0] is None or isinstance(ea[0][0], base.Domain))
5346                and (ea[0][1] is None or isinstance(ea[0][1], base.Zone))
5347                and (
5348                        ea[0][2] is None
5349                     or isinstance(ea[0][2], base.DecisionName)
5350                    )
5351                and isinstance(ea[0][3], base.MechanismName)
5352                and isinstance(ea[1], base.MechanismState)
5353                ):
5354                    mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom)
5355                    stateNow = base.stateOfMechanism(ctx, mID)
5356                    if not base.mechanismInStateOrEquivalent(
5357                        mID,
5358                        ea[1],
5359                        ctx
5360                    ):
5361                        self.warn(
5362                            f"'state' annotation expects mechanism {mID}"
5363                            f" {ea[0]!r} to be in state {ea[1]!r} but"
5364                            f" its current state is {stateNow!r} and no"
5365                            f" equivalence makes it count as being in"
5366                            f" state {ea[1]!r}."
5367                        )
5368                else:
5369                    self.warn(
5370                        f"'state' annotation expects mechanism state"
5371                        f" {a[6:]!r} but that's not a mechanism/state"
5372                        f" pair."
5373                    )
5374            elif a.startswith("exists:"):
5375                expects = pf.parseDecisionSpecifier(a[7:])
5376                try:
5377                    now.graph.resolveDecision(expects)
5378                except core.MissingDecisionError:
5379                    self.warn(
5380                        f"'exists' annotation expects decision"
5381                        f" {a[7:]!r} but that decision does not exist."
5382                    )

Records annotations to be applied to the current exploration step.

def recordAnnotateDecision(self, *annotations: str) -> None:
5384    def recordAnnotateDecision(
5385        self,
5386        *annotations: base.Annotation
5387    ) -> None:
5388        """
5389        Records annotations to be applied to the current decision.
5390        """
5391        now = self.exploration.getSituation()
5392        now.graph.annotateDecision(self.definiteDecisionTarget(), annotations)

Records annotations to be applied to the current decision.

def recordAnnotateTranstion(self, *annotations: str) -> None:
5394    def recordAnnotateTranstion(
5395        self,
5396        *annotations: base.Annotation
5397    ) -> None:
5398        """
5399        Records annotations to be applied to the most-recently-defined
5400        or -taken transition.
5401        """
5402        target = self.currentTransitionTarget()
5403        if target is None:
5404            raise JournalParseError(
5405                "Cannot annotate a transition because there is no"
5406                " current transition."
5407            )
5408
5409        now = self.exploration.getSituation()
5410        now.graph.annotateTransition(*target, annotations)

Records annotations to be applied to the most-recently-defined or -taken transition.

def recordAnnotateReciprocal(self, *annotations: str) -> None:
5412    def recordAnnotateReciprocal(
5413        self,
5414        *annotations: base.Annotation
5415    ) -> None:
5416        """
5417        Records annotations to be applied to the reciprocal of the
5418        most-recently-defined or -taken transition.
5419        """
5420        target = self.currentReciprocalTarget()
5421        if target is None:
5422            raise JournalParseError(
5423                "Cannot annotate a reciprocal because there is no"
5424                " current transition or because it doens't have a"
5425                " reciprocal."
5426            )
5427
5428        now = self.exploration.getSituation()
5429        now.graph.annotateTransition(*target, annotations)

Records annotations to be applied to the reciprocal of the most-recently-defined or -taken transition.

def recordAnnotateZone(self, level, *annotations: str) -> None:
5431    def recordAnnotateZone(
5432        self,
5433        level,
5434        *annotations: base.Annotation
5435    ) -> None:
5436        """
5437        Records annotations to be applied to the zone at the specified
5438        hierarchy level which contains the current decision. If there are
5439        multiple such zones, it picks the smallest one, breaking ties
5440        alphabetically by zone name (see `currentZoneAtLevel`).
5441        """
5442        applyTo = self.currentZoneAtLevel(level)
5443        self.exploration.getSituation().graph.annotateZone(
5444            applyTo,
5445            annotations
5446        )

Records annotations to be applied to the zone at the specified hierarchy level which contains the current decision. If there are multiple such zones, it picks the smallest one, breaking ties alphabetically by zone name (see currentZoneAtLevel).

def recordContextSwap(self, targetContext: Optional[str]) -> None:
5448    def recordContextSwap(
5449        self,
5450        targetContext: Optional[base.FocalContextName]
5451    ) -> None:
5452        """
5453        Records a swap of the active focal context, and/or a swap into
5454        "common"-context mode where all effects modify the common focal
5455        context instead of the active one. Use `None` as the argument to
5456        swap to common mode; use another specific value so swap to
5457        normal mode and set that context as the active one.
5458
5459        In relative mode, swaps the active context without adding an
5460        exploration step. Swapping into the common context never results
5461        in a new exploration step.
5462        """
5463        if targetContext is None:
5464            self.context['context'] = "common"
5465        else:
5466            self.context['context'] = "active"
5467            e = self.getExploration()
5468            if self.inRelativeMode:
5469                e.setActiveContext(targetContext)
5470            else:
5471                e.advanceSituation(('swap', targetContext))

Records a swap of the active focal context, and/or a swap into "common"-context mode where all effects modify the common focal context instead of the active one. Use None as the argument to swap to common mode; use another specific value so swap to normal mode and set that context as the active one.

In relative mode, swaps the active context without adding an exploration step. Swapping into the common context never results in a new exploration step.

def recordZone(self, level: int, zone: str) -> None:
5473    def recordZone(self, level: int, zone: base.Zone) -> None:
5474        """
5475        Records a new current zone to be swapped with the zone(s) at the
5476        specified hierarchy level for the current decision target. See
5477        `core.DiscreteExploration.reZone` and
5478        `core.DecisionGraph.replaceZonesInHierarchy` for details on what
5479        exactly happens; the summary is that the zones at the specified
5480        hierarchy level are replaced with the provided zone (which is
5481        created if necessary) and their children are re-parented onto
5482        the provided zone, while that zone is also set as a child of
5483        their parents.
5484
5485        Does the same thing in relative mode as in normal mode.
5486        """
5487        self.exploration.reZone(
5488            zone,
5489            self.definiteDecisionTarget(),
5490            level
5491        )

Records a new current zone to be swapped with the zone(s) at the specified hierarchy level for the current decision target. See core.DiscreteExploration.reZone and core.DecisionGraph.replaceZonesInHierarchy for details on what exactly happens; the summary is that the zones at the specified hierarchy level are replaced with the provided zone (which is created if necessary) and their children are re-parented onto the provided zone, while that zone is also set as a child of their parents.

Does the same thing in relative mode as in normal mode.

def recordUnify( self, merge: Union[int, exploration.base.DecisionSpecifier, str], mergeInto: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None) -> None:
5493    def recordUnify(
5494        self,
5495        merge: base.AnyDecisionSpecifier,
5496        mergeInto: Optional[base.AnyDecisionSpecifier] = None
5497    ) -> None:
5498        """
5499        Records a unification between two decisions. This marks an
5500        observation that they are actually the same decision and it
5501        merges them. If only one decision is given the current decision
5502        is merged into that one. After the merge, the first decision (or
5503        the current decision if only one was given) will no longer
5504        exist.
5505
5506        If one of the merged decisions was the current position in a
5507        singular-focalized domain, or one of the current positions in a
5508        plural- or spreading-focalized domain, the merged decision will
5509        replace it as a current decision after the merge, and this
5510        happens even when in relative mode. The target decision is also
5511        updated if it needs to be.
5512
5513        A `TransitionCollisionError` will be raised if the two decisions
5514        have outgoing transitions that share a name.
5515
5516        Logs a `JournalParseWarning` if the two decisions were in
5517        different zones.
5518
5519        Any transitions between the two merged decisions will remain in
5520        place as actions.
5521
5522        TODO: Option for removing self-edges after the merge? Option for
5523        doing that for just effect-less edges?
5524        """
5525        if mergeInto is None:
5526            mergeInto = merge
5527            merge = self.definiteDecisionTarget()
5528
5529        if isinstance(merge, str):
5530            merge = self.parseFormat.parseDecisionSpecifier(merge)
5531
5532        if isinstance(mergeInto, str):
5533            mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto)
5534
5535        now = self.exploration.getSituation()
5536
5537        if not isinstance(merge, base.DecisionID):
5538            merge = now.graph.resolveDecision(merge)
5539
5540        merge = cast(base.DecisionID, merge)
5541
5542        now.graph.mergeDecisions(merge, mergeInto)
5543
5544        mergedID = now.graph.resolveDecision(mergeInto)
5545
5546        # Update FocalContexts & ObservationContexts as necessary
5547        self.cleanupContexts(remapped={merge: mergedID})

Records a unification between two decisions. This marks an observation that they are actually the same decision and it merges them. If only one decision is given the current decision is merged into that one. After the merge, the first decision (or the current decision if only one was given) will no longer exist.

If one of the merged decisions was the current position in a singular-focalized domain, or one of the current positions in a plural- or spreading-focalized domain, the merged decision will replace it as a current decision after the merge, and this happens even when in relative mode. The target decision is also updated if it needs to be.

A TransitionCollisionError will be raised if the two decisions have outgoing transitions that share a name.

Logs a JournalParseWarning if the two decisions were in different zones.

Any transitions between the two merged decisions will remain in place as actions.

TODO: Option for removing self-edges after the merge? Option for doing that for just effect-less edges?

def recordUnifyTransition(self, target: str) -> None:
5549    def recordUnifyTransition(self, target: base.Transition) -> None:
5550        """
5551        Records a unification between the most-recently-defined or
5552        -taken transition and the specified transition (which must be
5553        outgoing from the same decision). This marks an observation that
5554        two transitions are actually the same transition and it merges
5555        them.
5556
5557        After the merge, the target transition will still exist but the
5558        previously most-recent transition will have been deleted.
5559
5560        Their reciprocals will also be merged.
5561
5562        A `JournalParseError` is raised if there is no most-recent
5563        transition.
5564        """
5565        now = self.exploration.getSituation()
5566        graph = now.graph
5567        affected = self.currentTransitionTarget()
5568        if affected is None or affected[1] is None:
5569            raise JournalParseError(
5570                "Cannot unify transitions: there is no current"
5571                " transition."
5572            )
5573
5574        decision, transition = affected
5575
5576        # If they don't share a target, then the current transition must
5577        # lead to an unknown node, which we will dispose of
5578        destination = graph.getDestination(decision, transition)
5579        if destination is None:
5580            raise JournalParseError(
5581                f"Cannot unify transitions: transition"
5582                f" {transition!r} at decision"
5583                f" {graph.identityOf(decision)} has no destination."
5584            )
5585
5586        finalDestination = graph.getDestination(decision, target)
5587        if finalDestination is None:
5588            raise JournalParseError(
5589                f"Cannot unify transitions: transition"
5590                f" {target!r} at decision {graph.identityOf(decision)}"
5591                f" has no destination."
5592            )
5593
5594        if destination != finalDestination:
5595            if graph.isConfirmed(destination):
5596                raise JournalParseError(
5597                    f"Cannot unify transitions: destination"
5598                    f" {graph.identityOf(destination)} of transition"
5599                    f" {transition!r} at decision"
5600                    f" {graph.identityOf(decision)} is not an"
5601                    f" unconfirmed decision."
5602                )
5603            # Retarget and delete the unknown node that we abandon
5604            # TODO: Merge nodes instead?
5605            now.graph.retargetTransition(
5606                decision,
5607                transition,
5608                finalDestination
5609            )
5610            now.graph.removeDecision(destination)
5611
5612        # Now we can merge transitions
5613        now.graph.mergeTransitions(decision, transition, target)
5614
5615        # Update targets if they were merged
5616        self.cleanupContexts(
5617            remappedTransitions={
5618                (decision, transition): (decision, target)
5619            }
5620        )

Records a unification between the most-recently-defined or -taken transition and the specified transition (which must be outgoing from the same decision). This marks an observation that two transitions are actually the same transition and it merges them.

After the merge, the target transition will still exist but the previously most-recent transition will have been deleted.

Their reciprocals will also be merged.

A JournalParseError is raised if there is no most-recent transition.

def recordUnifyReciprocal(self, target: str) -> None:
5622    def recordUnifyReciprocal(
5623        self,
5624        target: base.Transition
5625    ) -> None:
5626        """
5627        Records a unification between the reciprocal of the
5628        most-recently-defined or -taken transition and the specified
5629        transition, which must be outgoing from the current transition's
5630        destination. This marks an observation that two transitions are
5631        actually the same transition and it merges them, deleting the
5632        original reciprocal. Note that the current transition will also
5633        be merged with the reciprocal of the target.
5634
5635        A `JournalParseError` is raised if there is no current
5636        transition, or if it does not have a reciprocal.
5637        """
5638        now = self.exploration.getSituation()
5639        graph = now.graph
5640        affected = self.currentReciprocalTarget()
5641        if affected is None or affected[1] is None:
5642            raise JournalParseError(
5643                "Cannot unify transitions: there is no current"
5644                " transition."
5645            )
5646
5647        decision, transition = affected
5648
5649        destination = graph.destination(decision, transition)
5650        reciprocal = graph.getReciprocal(decision, transition)
5651        if reciprocal is None:
5652            raise JournalParseError(
5653                "Cannot unify reciprocal: there is no reciprocal of the"
5654                " current transition."
5655            )
5656
5657        # If they don't share a target, then the current transition must
5658        # lead to an unknown node, which we will dispose of
5659        finalDestination = graph.getDestination(destination, target)
5660        if finalDestination is None:
5661            raise JournalParseError(
5662                f"Cannot unify reciprocal: transition"
5663                f" {target!r} at decision"
5664                f" {graph.identityOf(destination)} has no destination."
5665            )
5666
5667        if decision != finalDestination:
5668            if graph.isConfirmed(decision):
5669                raise JournalParseError(
5670                    f"Cannot unify reciprocal: destination"
5671                    f" {graph.identityOf(decision)} of transition"
5672                    f" {reciprocal!r} at decision"
5673                    f" {graph.identityOf(destination)} is not an"
5674                    f" unconfirmed decision."
5675                )
5676            # Retarget and delete the unknown node that we abandon
5677            # TODO: Merge nodes instead?
5678            graph.retargetTransition(
5679                destination,
5680                reciprocal,
5681                finalDestination
5682            )
5683            graph.removeDecision(decision)
5684
5685        # Actually merge the transitions
5686        graph.mergeTransitions(destination, reciprocal, target)
5687
5688        # Update targets if they were merged
5689        self.cleanupContexts(
5690            remappedTransitions={
5691                (decision, transition): (decision, target)
5692            }
5693        )

Records a unification between the reciprocal of the most-recently-defined or -taken transition and the specified transition, which must be outgoing from the current transition's destination. This marks an observation that two transitions are actually the same transition and it merges them, deleting the original reciprocal. Note that the current transition will also be merged with the reciprocal of the target.

A JournalParseError is raised if there is no current transition, or if it does not have a reciprocal.

def recordObviate( self, transition: str, otherDecision: Union[int, exploration.base.DecisionSpecifier, str], otherTransition: str) -> None:
5695    def recordObviate(
5696        self,
5697        transition: base.Transition,
5698        otherDecision: base.AnyDecisionSpecifier,
5699        otherTransition: base.Transition
5700    ) -> None:
5701        """
5702        Records the obviation of a transition at another decision. This
5703        is the observation that a specific transition at the current
5704        decision is the reciprocal of a different transition at another
5705        decision which previously led to an unknown area. The difference
5706        between this and `recordReturn` is that `recordReturn` logs
5707        movement across the newly-connected transition, while this
5708        leaves the player at their original decision (and does not even
5709        add a step to the current exploration).
5710
5711        Both transitions will be created if they didn't already exist.
5712
5713        In relative mode does the same thing and doesn't move the current
5714        decision across the transition updated.
5715
5716        If the destination is unknown, it will remain unknown after this
5717        operation.
5718        """
5719        now = self.exploration.getSituation()
5720        graph = now.graph
5721        here = self.definiteDecisionTarget()
5722
5723        if isinstance(otherDecision, str):
5724            otherDecision = self.parseFormat.parseDecisionSpecifier(
5725                otherDecision
5726            )
5727
5728        # If we started with a name or some other kind of decision
5729        # specifier, replace missing domain and/or zone info with info
5730        # from the current decision.
5731        if isinstance(otherDecision, base.DecisionSpecifier):
5732            otherDecision = base.spliceDecisionSpecifiers(
5733                otherDecision,
5734                self.decisionTargetSpecifier()
5735            )
5736
5737        otherDestination = graph.getDestination(
5738            otherDecision,
5739            otherTransition
5740        )
5741        if otherDestination is not None:
5742            if graph.isConfirmed(otherDestination):
5743                raise JournalParseError(
5744                    f"Cannot obviate transition {otherTransition!r} at"
5745                    f" decision {graph.identityOf(otherDecision)}: that"
5746                    f" transition leads to decision"
5747                    f" {graph.identityOf(otherDestination)} which has"
5748                    f" already been visited."
5749                )
5750        else:
5751            # We must create the other destination
5752            graph.addUnexploredEdge(otherDecision, otherTransition)
5753
5754        destination = graph.getDestination(here, transition)
5755        if destination is not None:
5756            if graph.isConfirmed(destination):
5757                raise JournalParseError(
5758                    f"Cannot obviate using transition {transition!r} at"
5759                    f" decision {graph.identityOf(here)}: that"
5760                    f" transition leads to decision"
5761                    f" {graph.identityOf(destination)} which is not an"
5762                    f" unconfirmed decision."
5763                )
5764        else:
5765            # we need to create it
5766            graph.addUnexploredEdge(here, transition)
5767
5768        # Track exploration status of destination (because
5769        # `replaceUnconfirmed` will overwrite it but we want to preserve
5770        # it in this case.
5771        if otherDecision is not None:
5772            prevStatus = base.explorationStatusOf(now, otherDecision)
5773
5774        # Now connect the transitions and clean up the unknown nodes
5775        graph.replaceUnconfirmed(
5776            here,
5777            transition,
5778            otherDecision,
5779            otherTransition
5780        )
5781        # Restore exploration status
5782        base.setExplorationStatus(now, otherDecision, prevStatus)
5783
5784        # Update context
5785        self.context['transition'] = (here, transition)

Records the obviation of a transition at another decision. This is the observation that a specific transition at the current decision is the reciprocal of a different transition at another decision which previously led to an unknown area. The difference between this and recordReturn is that recordReturn logs movement across the newly-connected transition, while this leaves the player at their original decision (and does not even add a step to the current exploration).

Both transitions will be created if they didn't already exist.

In relative mode does the same thing and doesn't move the current decision across the transition updated.

If the destination is unknown, it will remain unknown after this operation.

def cleanupContexts( self, remapped: Optional[Dict[int, int]] = None, remappedTransitions: Optional[Dict[Tuple[int, str], Tuple[int, str]]] = None) -> None:
5787    def cleanupContexts(
5788        self,
5789        remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None,
5790        remappedTransitions: Optional[
5791            Dict[
5792                Tuple[base.DecisionID, base.Transition],
5793                Tuple[base.DecisionID, base.Transition]
5794            ]
5795        ] = None
5796    ) -> None:
5797        """
5798        Checks the validity of context decision and transition entries,
5799        and sets them to `None` in situations where they are no longer
5800        valid, affecting both the current and stored contexts.
5801
5802        Also updates position information in focal contexts in the
5803        current exploration step.
5804
5805        If a `remapped` dictionary is provided, decisions in the keys of
5806        that dictionary will be replaced with the corresponding value
5807        before being checked.
5808
5809        Similarly a `remappedTransitions` dicitonary may provide info on
5810        renamed transitions using (`base.DecisionID`, `base.Transition`)
5811        pairs as both keys and values.
5812        """
5813        if remapped is None:
5814            remapped = {}
5815
5816        if remappedTransitions is None:
5817            remappedTransitions = {}
5818
5819        # Fix broken position information in the current focal contexts
5820        now = self.exploration.getSituation()
5821        graph = now.graph
5822        state = now.state
5823        for ctx in (
5824            state['common'],
5825            state['contexts'][state['activeContext']]
5826        ):
5827            active = ctx['activeDecisions']
5828            for domain in active:
5829                aVal = active[domain]
5830                if isinstance(aVal, base.DecisionID):
5831                    if aVal in remapped:  # check for remap
5832                        aVal = remapped[aVal]
5833                        active[domain] = aVal
5834                    if graph.getDecision(aVal) is None: # Ultimately valid?
5835                        active[domain] = None
5836                elif isinstance(aVal, dict):
5837                    for fpName in aVal:
5838                        fpVal = aVal[fpName]
5839                        if fpVal is None:
5840                            aVal[fpName] = None
5841                        elif fpVal in remapped:  # check for remap
5842                            aVal[fpName] = remapped[fpVal]
5843                        elif graph.getDecision(fpVal) is None:  # valid?
5844                            aVal[fpName] = None
5845                elif isinstance(aVal, set):
5846                    for r in remapped:
5847                        if r in aVal:
5848                            aVal.remove(r)
5849                            aVal.add(remapped[r])
5850                    discard = []
5851                    for dID in aVal:
5852                        if graph.getDecision(dID) is None:
5853                            discard.append(dID)
5854                    for dID in discard:
5855                        aVal.remove(dID)
5856                elif aVal is not None:
5857                    raise RuntimeError(
5858                        f"Invalid active decisions for domain"
5859                        f" {repr(domain)}: {repr(aVal)}"
5860                    )
5861
5862        # Fix up our ObservationContexts
5863        fix = [self.context]
5864        if self.storedContext is not None:
5865            fix.append(self.storedContext)
5866
5867        graph = self.exploration.getSituation().graph
5868        for obsCtx in fix:
5869            cdID = obsCtx['decision']
5870            if cdID in remapped:
5871                cdID = remapped[cdID]
5872                obsCtx['decision'] = cdID
5873
5874            if cdID not in graph:
5875                obsCtx['decision'] = None
5876
5877            transition = obsCtx['transition']
5878            if transition is not None:
5879                tSourceID = transition[0]
5880                if tSourceID in remapped:
5881                    tSourceID = remapped[tSourceID]
5882                    obsCtx['transition'] = (tSourceID, transition[1])
5883
5884                if transition in remappedTransitions:
5885                    obsCtx['transition'] = remappedTransitions[transition]
5886
5887                tDestID = graph.getDestination(tSourceID, transition[1])
5888                if tDestID is None:
5889                    obsCtx['transition'] = None

Checks the validity of context decision and transition entries, and sets them to None in situations where they are no longer valid, affecting both the current and stored contexts.

Also updates position information in focal contexts in the current exploration step.

If a remapped dictionary is provided, decisions in the keys of that dictionary will be replaced with the corresponding value before being checked.

Similarly a remappedTransitions dicitonary may provide info on renamed transitions using (base.DecisionID, base.Transition) pairs as both keys and values.

def recordExtinguishDecision( self, target: Union[int, exploration.base.DecisionSpecifier, str]) -> None:
5891    def recordExtinguishDecision(
5892        self,
5893        target: base.AnyDecisionSpecifier
5894    ) -> None:
5895        """
5896        Records the deletion of a decision. The decision and all
5897        transitions connected to it will be removed from the current
5898        graph. Does not create a new exploration step. If the current
5899        position is deleted, the position will be set to `None`, or if
5900        we're in relative mode, the decision target will be set to
5901        `None` if it gets deleted. Likewise, all stored and/or current
5902        transitions which no longer exist are erased to `None`.
5903        """
5904        # Erase target if it's going to be removed
5905        now = self.exploration.getSituation()
5906
5907        if isinstance(target, str):
5908            target = self.parseFormat.parseDecisionSpecifier(target)
5909
5910        # TODO: Do we need to worry about the node being part of any
5911        # focal context data structures?
5912
5913        # Actually remove it
5914        now.graph.removeDecision(target)
5915
5916        # Clean up our contexts
5917        self.cleanupContexts()

Records the deletion of a decision. The decision and all transitions connected to it will be removed from the current graph. Does not create a new exploration step. If the current position is deleted, the position will be set to None, or if we're in relative mode, the decision target will be set to None if it gets deleted. Likewise, all stored and/or current transitions which no longer exist are erased to None.

def recordExtinguishTransition( self, source: Union[int, exploration.base.DecisionSpecifier, str], target: str, deleteReciprocal: bool = True) -> None:
5919    def recordExtinguishTransition(
5920        self,
5921        source: base.AnyDecisionSpecifier,
5922        target: base.Transition,
5923        deleteReciprocal: bool = True
5924    ) -> None:
5925        """
5926        Records the deletion of a named transition coming from a
5927        specific source. The reciprocal will also be removed, unless
5928        `deleteReciprocal` is set to False. If `deleteReciprocal` is
5929        used and this results in the complete isolation of an unknown
5930        node, that node will be deleted as well. Cleans up any saved
5931        transition targets that are no longer valid by setting them to
5932        `None`. Does not create a graph step.
5933        """
5934        now = self.exploration.getSituation()
5935        graph = now.graph
5936        dest = graph.destination(source, target)
5937
5938        # Remove the transition
5939        graph.removeTransition(source, target, deleteReciprocal)
5940
5941        # Remove the old destination if it's unconfirmed and no longer
5942        # connected anywhere
5943        if (
5944            not graph.isConfirmed(dest)
5945        and len(graph.destinationsFrom(dest)) == 0
5946        ):
5947            graph.removeDecision(dest)
5948
5949        # Clean up our contexts
5950        self.cleanupContexts()

Records the deletion of a named transition coming from a specific source. The reciprocal will also be removed, unless deleteReciprocal is set to False. If deleteReciprocal is used and this results in the complete isolation of an unknown node, that node will be deleted as well. Cleans up any saved transition targets that are no longer valid by setting them to None. Does not create a graph step.

def recordComplicate( self, target: str, newDecision: str, newReciprocal: Optional[str], newReciprocalReciprocal: Optional[str]) -> int:
5952    def recordComplicate(
5953        self,
5954        target: base.Transition,
5955        newDecision: base.DecisionName,  # TODO: Allow zones/domain here
5956        newReciprocal: Optional[base.Transition],
5957        newReciprocalReciprocal: Optional[base.Transition]
5958    ) -> base.DecisionID:
5959        """
5960        Records the complication of a transition and its reciprocal into
5961        a new decision. The old transition and its old reciprocal (if
5962        there was one) both point to the new decision. The
5963        `newReciprocal` becomes the new reciprocal of the original
5964        transition, and the `newReciprocalReciprocal` becomes the new
5965        reciprocal of the old reciprocal. Either may be set explicitly to
5966        `None` to leave the corresponding new transition without a
5967        reciprocal (but they don't default to `None`). If there was no
5968        old reciprocal, but `newReciprocalReciprocal` is specified, then
5969        that transition is created linking the new node to the old
5970        destination, without a reciprocal.
5971
5972        The current decision & transition information is not updated.
5973
5974        Returns the decision ID for the new node.
5975        """
5976        now = self.exploration.getSituation()
5977        graph = now.graph
5978        here = self.definiteDecisionTarget()
5979        domain = graph.domainFor(here)
5980
5981        oldDest = graph.destination(here, target)
5982        oldReciprocal = graph.getReciprocal(here, target)
5983
5984        # Create the new decision:
5985        newID = graph.addDecision(newDecision, domain=domain)
5986        # Note that the new decision is NOT an unknown decision
5987        # We copy the exploration status from the current decision
5988        self.exploration.setExplorationStatus(
5989            newID,
5990            self.exploration.getExplorationStatus(here)
5991        )
5992        # Copy over zone info
5993        for zp in graph.zoneParents(here):
5994            graph.addDecisionToZone(newID, zp)
5995
5996        # Retarget the transitions
5997        graph.retargetTransition(
5998            here,
5999            target,
6000            newID,
6001            swapReciprocal=False
6002        )
6003        if oldReciprocal is not None:
6004            graph.retargetTransition(
6005                oldDest,
6006                oldReciprocal,
6007                newID,
6008                swapReciprocal=False
6009            )
6010
6011        # Add a new reciprocal edge
6012        if newReciprocal is not None:
6013            graph.addTransition(newID, newReciprocal, here)
6014            graph.setReciprocal(here, target, newReciprocal)
6015
6016        # Add a new double-reciprocal edge (even if there wasn't a
6017        # reciprocal before)
6018        if newReciprocalReciprocal is not None:
6019            graph.addTransition(
6020                newID,
6021                newReciprocalReciprocal,
6022                oldDest
6023            )
6024            if oldReciprocal is not None:
6025                graph.setReciprocal(
6026                    oldDest,
6027                    oldReciprocal,
6028                    newReciprocalReciprocal
6029                )
6030
6031        return newID

Records the complication of a transition and its reciprocal into a new decision. The old transition and its old reciprocal (if there was one) both point to the new decision. The newReciprocal becomes the new reciprocal of the original transition, and the newReciprocalReciprocal becomes the new reciprocal of the old reciprocal. Either may be set explicitly to None to leave the corresponding new transition without a reciprocal (but they don't default to None). If there was no old reciprocal, but newReciprocalReciprocal is specified, then that transition is created linking the new node to the old destination, without a reciprocal.

The current decision & transition information is not updated.

Returns the decision ID for the new node.

def recordRevert( self, slot: str, aspects: Set[str], decisionType: Literal['pending', 'active', 'unintended', 'imposed', 'consequence'] = 'active') -> None:
6033    def recordRevert(
6034        self,
6035        slot: base.SaveSlot,
6036        aspects: Set[str],
6037        decisionType: base.DecisionType = 'active'
6038    ) -> None:
6039        """
6040        Records a reversion to a previous state (possibly for only some
6041        aspects of the current state). See `base.revertedState` for the
6042        allowed values and meanings of strings in the aspects set.
6043        Uses the specified decision type, or 'active' by default.
6044
6045        Reversion counts as an exploration step.
6046
6047        This sets the current decision to the primary decision for the
6048        reverted state (which might be `None` in some cases) and sets
6049        the current transition to None.
6050        """
6051        self.exploration.revert(slot, aspects, decisionType=decisionType)
6052        newPrimary = self.exploration.getSituation().state['primaryDecision']
6053        self.context['decision'] = newPrimary
6054        self.context['transition'] = None

Records a reversion to a previous state (possibly for only some aspects of the current state). See base.revertedState for the allowed values and meanings of strings in the aspects set. Uses the specified decision type, or 'active' by default.

Reversion counts as an exploration step.

This sets the current decision to the primary decision for the reverted state (which might be None in some cases) and sets the current transition to None.

def recordFulfills( self, requirement: Union[str, exploration.base.Requirement], fulfilled: Union[str, Tuple[int, str]]) -> None:
6056    def recordFulfills(
6057        self,
6058        requirement: Union[str, base.Requirement],
6059        fulfilled: Union[
6060            base.Capability,
6061            Tuple[base.MechanismID, base.MechanismState]
6062        ]
6063    ) -> None:
6064        """
6065        Records the observation that a certain requirement fulfills the
6066        same role as (i.e., is equivalent to) a specific capability, or a
6067        specific mechanism being in a specific state. Transitions that
6068        require that capability or mechanism state will count as
6069        traversable even if that capability is not obtained or that
6070        mechanism is in another state, as long as the requirement for the
6071        fulfillment is satisfied. If multiple equivalences are
6072        established, any one of them being satisfied will count as that
6073        capability being obtained (or the mechanism being in the
6074        specified state). Note that if a circular dependency is created,
6075        the capability or mechanism (unless actually obtained or in the
6076        target state) will be considered as not being obtained (or in the
6077        target state) during recursive checks.
6078        """
6079        if isinstance(requirement, str):
6080            requirement = self.parseFormat.parseRequirement(requirement)
6081
6082        self.getExploration().getSituation().graph.addEquivalence(
6083            requirement,
6084            fulfilled
6085        )

Records the observation that a certain requirement fulfills the same role as (i.e., is equivalent to) a specific capability, or a specific mechanism being in a specific state. Transitions that require that capability or mechanism state will count as traversable even if that capability is not obtained or that mechanism is in another state, as long as the requirement for the fulfillment is satisfied. If multiple equivalences are established, any one of them being satisfied will count as that capability being obtained (or the mechanism being in the specified state). Note that if a circular dependency is created, the capability or mechanism (unless actually obtained or in the target state) will be considered as not being obtained (or in the target state) during recursive checks.

def recordFocusOn( self, newFocalPoint: str, inDomain: Optional[str] = None, inCommon: bool = False):
6087    def recordFocusOn(
6088        self,
6089        newFocalPoint: base.FocalPointName,
6090        inDomain: Optional[base.Domain] = None,
6091        inCommon: bool = False
6092    ):
6093        """
6094        Records a swap to a new focal point, setting that focal point as
6095        the active focal point in the observer's current domain, or in
6096        the specified domain if one is specified.
6097
6098        A `JournalParseError` is raised if the current/specified domain
6099        does not have plural focalization. If it doesn't have a focal
6100        point with that name, then one is created and positioned at the
6101        observer's current decision (which must be in the appropriate
6102        domain).
6103
6104        If `inCommon` is set to `True` (default is `False`) then the
6105        changes will be applied to the common context instead of the
6106        active context.
6107
6108        Note that this does *not* make the target domain active; use
6109        `recordDomainFocus` for that if you need to.
6110        """
6111        if inDomain is None:
6112            inDomain = self.context['domain']
6113
6114        if inCommon:
6115            ctx = self.getExploration().getCommonContext()
6116        else:
6117            ctx = self.getExploration().getActiveContext()
6118
6119        if ctx['focalization'].get('domain') != 'plural':
6120            raise JournalParseError(
6121                f"Domain {inDomain!r} does not exist or does not have"
6122                f" plural focalization, so we can't set a focal point"
6123                f" in it."
6124            )
6125
6126        focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {})
6127        if not isinstance(focalPointMap, dict):
6128            raise RuntimeError(
6129                f"Plural-focalized domain {inDomain!r} has"
6130                f" non-dictionary active"
6131                f" decisions:\n{repr(focalPointMap)}"
6132            )
6133
6134        if newFocalPoint not in focalPointMap:
6135            focalPointMap[newFocalPoint] = self.context['decision']
6136
6137        self.context['focus'] = newFocalPoint
6138        self.context['decision'] = focalPointMap[newFocalPoint]

Records a swap to a new focal point, setting that focal point as the active focal point in the observer's current domain, or in the specified domain if one is specified.

A JournalParseError is raised if the current/specified domain does not have plural focalization. If it doesn't have a focal point with that name, then one is created and positioned at the observer's current decision (which must be in the appropriate domain).

If inCommon is set to True (default is False) then the changes will be applied to the common context instead of the active context.

Note that this does not make the target domain active; use recordDomainFocus for that if you need to.

def recordDomainUnfocus(self, domain: str, inCommon: bool = False):
6140    def recordDomainUnfocus(
6141        self,
6142        domain: base.Domain,
6143        inCommon: bool = False
6144    ):
6145        """
6146        Records a domain losing focus. Does not raise an error if the
6147        target domain was not active (in that case, it doesn't need to
6148        do anything).
6149
6150        If `inCommon` is set to `True` (default is `False`) then the
6151        domain changes will be applied to the common context instead of
6152        the active context.
6153        """
6154        if inCommon:
6155            ctx = self.getExploration().getCommonContext()
6156        else:
6157            ctx = self.getExploration().getActiveContext()
6158
6159        try:
6160            ctx['activeDomains'].remove(domain)
6161        except KeyError:
6162            pass

Records a domain losing focus. Does not raise an error if the target domain was not active (in that case, it doesn't need to do anything).

If inCommon is set to True (default is False) then the domain changes will be applied to the common context instead of the active context.

def recordDomainFocus(self, domain: str, exclusive: bool = False, inCommon: bool = False):
6164    def recordDomainFocus(
6165        self,
6166        domain: base.Domain,
6167        exclusive: bool = False,
6168        inCommon: bool = False
6169    ):
6170        """
6171        Records a domain gaining focus, activating that domain in the
6172        current focal context and setting it as the observer's current
6173        domain. If the domain named doesn't exist yet, it will be
6174        created first (with default focalization) and then focused.
6175
6176        If `exclusive` is set to `True` (default is `False`) then all
6177        other active domains will be deactivated.
6178
6179        If `inCommon` is set to `True` (default is `False`) then the
6180        domain changes will be applied to the common context instead of
6181        the active context.
6182        """
6183        if inCommon:
6184            ctx = self.getExploration().getCommonContext()
6185        else:
6186            ctx = self.getExploration().getActiveContext()
6187
6188        if exclusive:
6189            ctx['activeDomains'] = set()
6190
6191        if domain not in ctx['focalization']:
6192            self.recordNewDomain(domain, inCommon=inCommon)
6193        else:
6194            ctx['activeDomains'].add(domain)
6195
6196        self.context['domain'] = domain

Records a domain gaining focus, activating that domain in the current focal context and setting it as the observer's current domain. If the domain named doesn't exist yet, it will be created first (with default focalization) and then focused.

If exclusive is set to True (default is False) then all other active domains will be deactivated.

If inCommon is set to True (default is False) then the domain changes will be applied to the common context instead of the active context.

def recordNewDomain( self, domain: str, focalization: Literal['singular', 'plural', 'spreading'] = 'singular', inCommon: bool = False):
6198    def recordNewDomain(
6199        self,
6200        domain: base.Domain,
6201        focalization: base.DomainFocalization = "singular",
6202        inCommon: bool = False
6203    ):
6204        """
6205        Records a new domain, setting it up with the specified
6206        focalization. Sets that domain as an active domain and as the
6207        journal's current domain so that subsequent entries will create
6208        decisions in that domain. However, it does not activate any
6209        decisions within that domain.
6210
6211        Raises a `JournalParseError` if the specified domain already
6212        exists.
6213
6214        If `inCommon` is set to `True` (default is `False`) then the new
6215        domain will be made active in the common context instead of the
6216        active context.
6217        """
6218        if inCommon:
6219            ctx = self.getExploration().getCommonContext()
6220        else:
6221            ctx = self.getExploration().getActiveContext()
6222
6223        if domain in ctx['focalization']:
6224            raise JournalParseError(
6225                f"Cannot create domain {domain!r}: that domain already"
6226                f" exists."
6227            )
6228
6229        ctx['focalization'][domain] = focalization
6230        ctx['activeDecisions'][domain] = None
6231        ctx['activeDomains'].add(domain)
6232        self.context['domain'] = domain

Records a new domain, setting it up with the specified focalization. Sets that domain as an active domain and as the journal's current domain so that subsequent entries will create decisions in that domain. However, it does not activate any decisions within that domain.

Raises a JournalParseError if the specified domain already exists.

If inCommon is set to True (default is False) then the new domain will be made active in the common context instead of the active context.

def relative( self, where: Union[int, exploration.base.DecisionSpecifier, str, NoneType] = None, transition: Optional[str] = None) -> None:
6234    def relative(
6235        self,
6236        where: Optional[base.AnyDecisionSpecifier] = None,
6237        transition: Optional[base.Transition] = None,
6238    ) -> None:
6239        """
6240        Enters 'relative mode' where the exploration ceases to add new
6241        steps but edits can still be performed on the current graph. This
6242        also changes the current decision/transition settings so that
6243        edits can be applied anywhere. It can accept 0, 1, or 2
6244        arguments. With 0 arguments, it simply enters relative mode but
6245        maintains the current position as the target decision and the
6246        last-taken or last-created transition as the target transition
6247        (note that that transition usually originates at a different
6248        decision). With 1 argument, it sets the target decision to the
6249        decision named, and sets the target transition to None. With 2
6250        arguments, it sets the target decision to the decision named, and
6251        the target transition to the transition named, which must
6252        originate at that target decision. If the first argument is None,
6253        the current decision is used.
6254
6255        If given the name of a decision which does not yet exist, it will
6256        create that decision in the current graph, disconnected from the
6257        rest of the graph. In that case, it is an error to also supply a
6258        transition to target (you can use other commands once in relative
6259        mode to build more transitions and decisions out from the
6260        newly-created decision).
6261
6262        When called in relative mode, it updates the current position
6263        and/or decision, or if called with no arguments, it exits
6264        relative mode. When exiting relative mode, the current decision
6265        is set back to the graph's current position, and the current
6266        transition is set to whatever it was before relative mode was
6267        entered.
6268
6269        Raises a `TypeError` if a transition is specified without
6270        specifying a decision. Raises a `ValueError` if given no
6271        arguments and the exploration does not have a current position.
6272        Also raises a `ValueError` if told to target a specific
6273        transition which does not exist.
6274
6275        TODO: Example here!
6276        """
6277        # TODO: Not this?
6278        if where is None:
6279            if transition is None and self.inRelativeMode:
6280                # If we're in relative mode, cancel it
6281                self.inRelativeMode = False
6282
6283                # Here we restore saved sate
6284                if self.storedContext is None:
6285                    raise RuntimeError(
6286                        "No stored context despite being in relative"
6287                        "mode."
6288                    )
6289                self.context = self.storedContext
6290                self.storedContext = None
6291
6292            else:
6293                # Enter or stay in relative mode and set up the current
6294                # decision/transition as the targets
6295
6296                # Ensure relative mode
6297                self.inRelativeMode = True
6298
6299                # Store state
6300                self.storedContext = self.context
6301                where = self.storedContext['decision']
6302                if where is None:
6303                    raise ValueError(
6304                        "Cannot enter relative mode at the current"
6305                        " position because there is no current"
6306                        " position."
6307                    )
6308
6309                self.context = observationContext(
6310                    context=self.storedContext['context'],
6311                    domain=self.storedContext['domain'],
6312                    focus=self.storedContext['focus'],
6313                    decision=where,
6314                    transition=(
6315                        None
6316                        if transition is None
6317                        else (where, transition)
6318                    )
6319                )
6320
6321        else: # we have at least a decision to target
6322            # If we're entering relative mode instead of just changing
6323            # focus, we need to set up the current transition if no
6324            # transition was specified.
6325            entering: Optional[
6326                Tuple[
6327                    base.ContextSpecifier,
6328                    base.Domain,
6329                    Optional[base.FocalPointName]
6330                ]
6331            ] = None
6332            if not self.inRelativeMode:
6333                # We'll be entering relative mode, so store state
6334                entering = (
6335                    self.context['context'],
6336                    self.context['domain'],
6337                    self.context['focus']
6338                )
6339                self.storedContext = self.context
6340                if transition is None:
6341                    oldTransitionPair = self.context['transition']
6342                    if oldTransitionPair is not None:
6343                        oldBase, oldTransition = oldTransitionPair
6344                        if oldBase == where:
6345                            transition = oldTransition
6346
6347            # Enter (or stay in) relative mode
6348            self.inRelativeMode = True
6349
6350            now = self.exploration.getSituation()
6351            whereID: Optional[base.DecisionID]
6352            whereSpec: Optional[base.DecisionSpecifier] = None
6353            if isinstance(where, str):
6354                where = self.parseFormat.parseDecisionSpecifier(where)
6355                # might turn it into a DecisionID
6356
6357            if isinstance(where, base.DecisionID):
6358                whereID = where
6359            elif isinstance(where, base.DecisionSpecifier):
6360                # Add in current zone + domain info if those things
6361                # aren't explicit
6362                if self.currentDecisionTarget() is not None:
6363                    where = base.spliceDecisionSpecifiers(
6364                        where,
6365                        self.decisionTargetSpecifier()
6366                    )
6367                elif where.domain is None:
6368                    # Splice in current domain if needed
6369                    where = base.DecisionSpecifier(
6370                        domain=self.context['domain'],
6371                        zone=where.zone,
6372                        name=where.name
6373                    )
6374                whereID = now.graph.getDecision(where)  # might be None
6375                whereSpec = where
6376            else:
6377                raise TypeError(f"Invalid decision specifier: {where!r}")
6378
6379            # Create a new decision if necessary
6380            if whereID is None:
6381                if transition is not None:
6382                    raise TypeError(
6383                        f"Cannot specify a target transition when"
6384                        f" entering relative mode at previously"
6385                        f" non-existent decision"
6386                        f" {now.graph.identityOf(where)}."
6387                    )
6388                assert whereSpec is not None
6389                whereID = now.graph.addDecision(
6390                    whereSpec.name,
6391                    domain=whereSpec.domain
6392                )
6393                if whereSpec.zone is not None:
6394                    now.graph.addDecisionToZone(whereID, whereSpec.zone)
6395
6396            # Create the new context if we're entering relative mode
6397            if entering is not None:
6398                self.context = observationContext(
6399                    context=entering[0],
6400                    domain=entering[1],
6401                    focus=entering[2],
6402                    decision=whereID,
6403                    transition=(
6404                        None
6405                        if transition is None
6406                        else (whereID, transition)
6407                    )
6408                )
6409
6410            # Target the specified decision
6411            self.context['decision'] = whereID
6412
6413            # Target the specified transition
6414            if transition is not None:
6415                self.context['transition'] = (whereID, transition)
6416                if now.graph.getDestination(where, transition) is None:
6417                    raise ValueError(
6418                        f"Cannot target transition {transition!r} at"
6419                        f" decision {now.graph.identityOf(where)}:"
6420                        f" there is no such transition."
6421                    )
6422            # otherwise leave self.context['transition'] alone

Enters 'relative mode' where the exploration ceases to add new steps but edits can still be performed on the current graph. This also changes the current decision/transition settings so that edits can be applied anywhere. It can accept 0, 1, or 2 arguments. With 0 arguments, it simply enters relative mode but maintains the current position as the target decision and the last-taken or last-created transition as the target transition (note that that transition usually originates at a different decision). With 1 argument, it sets the target decision to the decision named, and sets the target transition to None. With 2 arguments, it sets the target decision to the decision named, and the target transition to the transition named, which must originate at that target decision. If the first argument is None, the current decision is used.

If given the name of a decision which does not yet exist, it will create that decision in the current graph, disconnected from the rest of the graph. In that case, it is an error to also supply a transition to target (you can use other commands once in relative mode to build more transitions and decisions out from the newly-created decision).

When called in relative mode, it updates the current position and/or decision, or if called with no arguments, it exits relative mode. When exiting relative mode, the current decision is set back to the graph's current position, and the current transition is set to whatever it was before relative mode was entered.

Raises a TypeError if a transition is specified without specifying a decision. Raises a ValueError if given no arguments and the exploration does not have a current position. Also raises a ValueError if told to target a specific transition which does not exist.

TODO: Example here!

def convertJournal( journal: str, fmt: Optional[JournalParseFormat] = None) -> exploration.core.DiscreteExploration:
6429def convertJournal(
6430    journal: str,
6431    fmt: Optional[JournalParseFormat] = None
6432) -> core.DiscreteExploration:
6433    """
6434    Converts a journal in text format into a `core.DiscreteExploration`
6435    object, using a fresh `JournalObserver`. An optional `ParseFormat`
6436    may be specified if the journal doesn't follow the default parse
6437    format.
6438    """
6439    obs = JournalObserver(fmt)
6440    obs.observe(journal)
6441    return obs.getExploration()

Converts a journal in text format into a core.DiscreteExploration object, using a fresh JournalObserver. An optional ParseFormat may be specified if the journal doesn't follow the default parse format.