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: Optional[base.Zone] = base.DefaultZone
4148        newName: Optional[base.DecisionName]
4149
4150        # if a destination is specified, we need to check that it's not
4151        # an already-existing decision
4152        connectBack: bool = False  # are we connecting to a known decision?
4153        if destination is not None:
4154            # If it's not an ID, splice in current node info:
4155            if isinstance(destination, base.DecisionName):
4156                destination = base.DecisionSpecifier(None, None, destination)
4157            if isinstance(destination, base.DecisionSpecifier):
4158                destination = base.spliceDecisionSpecifiers(
4159                    destination,
4160                    self.decisionTargetSpecifier()
4161                )
4162            exists = graph.getDecision(destination)
4163            # if the specified decision doesn't exist; great. We'll
4164            # create it below
4165            if exists is not None:
4166                # If it does exist, we may have a problem. 'return' must
4167                # be used instead of 'explore' to connect to an existing
4168                # visited decision. But let's see if we really have a
4169                # conflict?
4170                otherZones = set(
4171                    z
4172                    for z in graph.zoneParents(exists)
4173                    if graph.zoneHierarchyLevel(z) == 0
4174                )
4175                currentZones = set(
4176                    z
4177                    for z in graph.zoneParents(here)
4178                    if graph.zoneHierarchyLevel(z) == 0
4179                )
4180                if (
4181                    len(otherZones & currentZones) != 0
4182                 or (
4183                        len(otherZones) == 0
4184                    and len(currentZones) == 0
4185                    )
4186                ):
4187                    if self.exploration.hasBeenVisited(exists):
4188                        # A decision by this name exists and shares at
4189                        # least one level-0 zone with the current
4190                        # decision. That means that 'return' should have
4191                        # been used.
4192                        raise JournalParseError(
4193                            f"Destiation {destination} is invalid"
4194                            f" because that decision has already been"
4195                            f" visited in the current zone. Use"
4196                            f" 'return' to record a new connection to"
4197                            f" an already-visisted decision."
4198                        )
4199                    else:
4200                        connectBack = True
4201                else:
4202                    connectBack = True
4203                # Otherwise, we can continue; the DefaultZone setting
4204                # already in place will prevail below
4205
4206        # Figure out domain & zone info for new destination
4207        if isinstance(destination, base.DecisionSpecifier):
4208            # Use current decision's domain by default
4209            if destination.domain is not None:
4210                newDomain = destination.domain
4211            else:
4212                newDomain = graph.domainFor(here)
4213
4214            # Use specified zone if there is one, else leave it as
4215            # DefaultZone to inherit zone(s) from the current decision.
4216            if destination.zone is not None:
4217                newZone = destination.zone
4218
4219            newName = destination.name
4220            # TODO: Some way to specify non-zone placement in explore?
4221
4222        elif isinstance(destination, base.DecisionID):
4223            if connectBack:
4224                newDomain = graph.domainFor(here)
4225                newZone = None
4226                newName = None
4227            else:
4228                raise JournalParseError(
4229                    f"You cannot use a decision ID when specifying a"
4230                    f" new name for an exploration destination (got:"
4231                    f" {repr(destination)})"
4232                )
4233
4234        elif isinstance(destination, base.DecisionName):
4235            newDomain = None
4236            newZone = base.DefaultZone
4237            newName = destination
4238
4239        else:  # must be None
4240            assert destination is None
4241            newDomain = None
4242            newZone = base.DefaultZone
4243            newName = None
4244
4245        if leadsTo is None:
4246            if newName is None and not connectBack:
4247                raise JournalParseError(
4248                    f"Transition {transition!r} at decision"
4249                    f" {graph.identityOf(here)} does not already exist,"
4250                    f" so a destination name must be provided."
4251                )
4252            else:
4253                graph.addUnexploredEdge(
4254                    here,
4255                    transitionName,
4256                    toDomain=newDomain  # None is the default anyways
4257                )
4258                # Zone info only added in next step
4259        elif newName is None:
4260            # TODO: Generalize this... ?
4261            currentName = graph.nameFor(leadsTo)
4262            if currentName.startswith('_u.'):
4263                raise JournalParseError(
4264                    f"Destination {graph.identityOf(leadsTo)} from"
4265                    f" decision {graph.identityOf(here)} via transition"
4266                    f" {transition!r} must be named when explored,"
4267                    f" because its current name is a placeholder."
4268                )
4269            else:
4270                newName = currentName
4271
4272        # TODO: Check for incompatible domain/zone in destination
4273        # specifier?
4274
4275        if self.inRelativeMode:
4276            if connectBack:  # connect to existing unconfirmed decision
4277                assert exists is not None
4278                graph.replaceUnconfirmed(
4279                    here,
4280                    transitionName,
4281                    exists,
4282                    reciprocal
4283                )  # we assume zones are already in place here
4284                self.exploration.setExplorationStatus(
4285                    exists,
4286                    'noticed',
4287                    upgradeOnly=True
4288                )
4289            else:  # connect to a new decision
4290                graph.replaceUnconfirmed(
4291                    here,
4292                    transitionName,
4293                    newName,
4294                    reciprocal,
4295                    placeInZone=newZone,
4296                    forceNew=True
4297                )
4298                destID = graph.destination(here, transitionName)
4299                self.exploration.setExplorationStatus(
4300                    destID,
4301                    'noticed',
4302                    upgradeOnly=True
4303                )
4304            self.context['decision'] = graph.destination(
4305                here,
4306                transitionName
4307            )
4308            self.context['transition'] = (here, transitionName)
4309        else:
4310            if connectBack:  # to a known but unvisited decision
4311                destID = self.exploration.explore(
4312                    (transitionName, outcomes),
4313                    exists,
4314                    reciprocal,
4315                    zone=newZone,
4316                    decisionType=decisionType
4317                )
4318            else:  # to an entirely new decision
4319                destID = self.exploration.explore(
4320                    (transitionName, outcomes),
4321                    newName,
4322                    reciprocal,
4323                    zone=newZone,
4324                    decisionType=decisionType
4325                )
4326            self.context['decision'] = destID
4327            self.context['transition'] = (here, transitionName)
4328            self.autoFinalizeExplorationStatuses()
4329
4330    def recordRetrace(
4331        self,
4332        transition: base.AnyTransition,
4333        decisionType: base.DecisionType = 'active',
4334        isAction: Optional[bool] = None
4335    ) -> None:
4336        """
4337        Records retracing a transition which leads to a known
4338        destination. A non-default decision type can be specified. If
4339        `isAction` is True or False, the transition must be (or must not
4340        be) an action (i.e., a transition whose destination is the same
4341        as its source). If `isAction` is left as `None` (the default)
4342        then either normal or action transitions can be retraced.
4343
4344        Sets the current transition to the transition taken.
4345
4346        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4347
4348        In relative mode, simply sets the current transition target to
4349        the transition taken and sets the current decision target to its
4350        destination (it does not apply transition effects).
4351        """
4352        here = self.definiteDecisionTarget()
4353
4354        transitionName, outcomes = base.nameAndOutcomes(transition)
4355
4356        graph = self.exploration.getSituation().graph
4357        destination = graph.getDestination(here, transitionName)
4358        if destination is None:
4359            valid = graph.destinationsListing(graph.destinationsFrom(here))
4360            raise JournalParseError(
4361                f"Cannot retrace transition {transitionName!r} from"
4362                f" decision {graph.identityOf(here)}: that transition"
4363                f" does not exist. Destinations available are:"
4364                f"\n{valid}"
4365            )
4366        if isAction is True and destination != here:
4367            raise JournalParseError(
4368                f"Cannot retrace transition {transitionName!r} from"
4369                f" decision {graph.identityOf(here)}: that transition"
4370                f" leads to {graph.identityOf(destination)} but you"
4371                f" specified that an existing action should be retraced,"
4372                f" not a normal transition. Use `recordAction` instead"
4373                f" to record a new action (including converting an"
4374                f" unconfirmed transition into an action). Leave"
4375                f" `isAction` unspeicfied or set it to `False` to"
4376                f" retrace a normal transition."
4377            )
4378        elif isAction is False and destination == here:
4379            raise JournalParseError(
4380                f"Cannot retrace transition {transitionName!r} from"
4381                f" decision {graph.identityOf(here)}: that transition"
4382                f" leads back to {graph.identityOf(destination)} but you"
4383                f" specified that an outgoing transition should be"
4384                f" retraced, not an action. Use `recordAction` instead"
4385                f" to record a new action (which must not have the same"
4386                f" name as any outgoing transition). Leave `isAction`"
4387                f" unspeicfied or set it to `True` to retrace an action."
4388            )
4389
4390        if not self.inRelativeMode:
4391            destID = self.exploration.retrace(
4392                (transitionName, outcomes),
4393                decisionType=decisionType
4394            )
4395            self.autoFinalizeExplorationStatuses()
4396        self.context['decision'] = destID
4397        self.context['transition'] = (here, transitionName)
4398
4399    def recordAction(
4400        self,
4401        action: base.AnyTransition,
4402        decisionType: base.DecisionType = 'active'
4403    ) -> None:
4404        """
4405        Records a new action taken at the current decision. A
4406        non-standard decision type may be specified. If a transition of
4407        that name already existed, it will be converted into an action
4408        assuming that its destination is unexplored and has no
4409        connections yet, and that its reciprocal also has no special
4410        properties yet. If those assumptions do not hold, a
4411        `JournalParseError` will be raised under the assumption that the
4412        name collision was an accident, not intentional, since the
4413        destination and reciprocal are deleted in the process of
4414        converting a normal transition into an action.
4415
4416        This cannot be used to re-triggger an existing action, use
4417        'retrace' for that.
4418
4419        In relative mode, the action is created (or the transition is
4420        converted into an action) but effects are not applied.
4421
4422        Although this does not usually change which decisions are
4423        active, it still calls `autoFinalizeExplorationStatuses` unless
4424        in relative mode.
4425
4426        Example:
4427
4428        >>> o = JournalObserver()
4429        >>> e = o.getExploration()
4430        >>> o.recordStart('start')
4431        >>> o.recordObserve('transition')
4432        >>> e.effectiveCapabilities()['capabilities']
4433        set()
4434        >>> o.recordObserveAction('action')
4435        >>> o.recordTransitionConsequence([base.effect(gain="capability")])
4436        >>> o.recordRetrace('action', isAction=True)
4437        >>> e.effectiveCapabilities()['capabilities']
4438        {'capability'}
4439        >>> o.recordAction('another') # add effects after...
4440        >>> effect = base.effect(lose="capability")
4441        >>> # This applies the effect and then adds it to the
4442        >>> # transition, since we already took the transition
4443        >>> o.recordAdditionalTransitionConsequence([effect])
4444        >>> e.effectiveCapabilities()['capabilities']
4445        set()
4446        >>> len(e)
4447        4
4448        >>> e.getActiveDecisions(0)
4449        set()
4450        >>> e.getActiveDecisions(1)
4451        {0}
4452        >>> e.getActiveDecisions(2)
4453        {0}
4454        >>> e.getActiveDecisions(3)
4455        {0}
4456        >>> e.getSituation(0).action
4457        ('start', 0, 0, 'main', None, None, None)
4458        >>> e.getSituation(1).action
4459        ('take', 'active', 0, ('action', []))
4460        >>> e.getSituation(2).action
4461        ('take', 'active', 0, ('another', []))
4462        """
4463        here = self.definiteDecisionTarget()
4464
4465        actionName, outcomes = base.nameAndOutcomes(action)
4466
4467        # Check if the transition already exists
4468        now = self.exploration.getSituation()
4469        graph = now.graph
4470        hereIdent = graph.identityOf(here)
4471        destinations = graph.destinationsFrom(here)
4472
4473        # A transition going somewhere else
4474        if actionName in destinations:
4475            if destinations[actionName] == here:
4476                raise JournalParseError(
4477                    f"Action {actionName!r} already exists as an action"
4478                    f" at decision {hereIdent!r}. Use 'retrace' to"
4479                    " re-activate an existing action."
4480                )
4481            else:
4482                destination = destinations[actionName]
4483                reciprocal = graph.getReciprocal(here, actionName)
4484                # To replace a transition with an action, the transition
4485                # may only have outgoing properties. Otherwise we assume
4486                # it's an error to name the action after a transition
4487                # which was intended to be a real transition.
4488                if (
4489                    graph.isConfirmed(destination)
4490                 or self.exploration.hasBeenVisited(destination)
4491                 or cast(int, graph.degree(destination)) > 2
4492                    # TODO: Fix MultiDigraph type stubs...
4493                ):
4494                    raise JournalParseError(
4495                        f"Action {actionName!r} has the same name as"
4496                        f" outgoing transition {actionName!r} at"
4497                        f" decision {hereIdent!r}. We cannot turn that"
4498                        f" transition into an action since its"
4499                        f" destination is already explored or has been"
4500                        f" connected to."
4501                    )
4502                if (
4503                    reciprocal is not None
4504                and graph.getTransitionProperties(
4505                        destination,
4506                        reciprocal
4507                    ) != {
4508                        'requirement': base.ReqNothing(),
4509                        'effects': [],
4510                        'tags': {},
4511                        'annotations': []
4512                    }
4513                ):
4514                    raise JournalParseError(
4515                        f"Action {actionName!r} has the same name as"
4516                        f" outgoing transition {actionName!r} at"
4517                        f" decision {hereIdent!r}. We cannot turn that"
4518                        f" transition into an action since its"
4519                        f" reciprocal has custom properties."
4520                    )
4521
4522                if (
4523                    graph.decisionAnnotations(destination) != []
4524                 or graph.decisionTags(destination) != {'unknown': 1}
4525                ):
4526                    raise JournalParseError(
4527                        f"Action {actionName!r} has the same name as"
4528                        f" outgoing transition {actionName!r} at"
4529                        f" decision {hereIdent!r}. We cannot turn that"
4530                        f" transition into an action since its"
4531                        f" destination has tags and/or annotations."
4532                    )
4533
4534                # If we get here, re-target the transition, and then
4535                # destroy the old destination along with the old
4536                # reciprocal edge.
4537                graph.retargetTransition(
4538                    here,
4539                    actionName,
4540                    here,
4541                    swapReciprocal=False
4542                )
4543                graph.removeDecision(destination)
4544
4545        # This will either take the existing action OR create it if
4546        # necessary
4547        if self.inRelativeMode:
4548            if actionName not in destinations:
4549                graph.addAction(here, actionName)
4550        else:
4551            destID = self.exploration.takeAction(
4552                (actionName, outcomes),
4553                fromDecision=here,
4554                decisionType=decisionType
4555            )
4556            self.autoFinalizeExplorationStatuses()
4557            self.context['decision'] = destID
4558        self.context['transition'] = (here, actionName)
4559
4560    def recordReturn(
4561        self,
4562        transition: base.AnyTransition,
4563        destination: Optional[base.AnyDecisionSpecifier] = None,
4564        reciprocal: Optional[base.Transition] = None,
4565        decisionType: base.DecisionType = 'active'
4566    ) -> None:
4567        """
4568        Records an exploration which leads back to a
4569        previously-encountered decision. If a reciprocal is specified,
4570        we connect to that transition as our reciprocal (it must have
4571        led to an unknown area or not have existed) or if not, we make a
4572        new connection with an automatic reciprocal name.
4573        A non-standard decision type may be specified.
4574
4575        If no destination is specified, then the destination of the
4576        transition must already exist.
4577
4578        If the specified transition does not exist, it will be created.
4579
4580        Sets the current transition to the transition taken.
4581
4582        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4583
4584        In relative mode, does the same stuff but doesn't apply any
4585        transition effects.
4586        """
4587        here = self.definiteDecisionTarget()
4588        now = self.exploration.getSituation()
4589        graph = now.graph
4590
4591        transitionName, outcomes = base.nameAndOutcomes(transition)
4592
4593        if destination is None:
4594            destination = graph.getDestination(here, transitionName)
4595            if destination is None:
4596                raise JournalParseError(
4597                    f"Cannot 'return' across transition"
4598                    f" {transitionName!r} from decision"
4599                    f" {graph.identityOf(here)} without specifying a"
4600                    f" destination, because that transition does not"
4601                    f" already have a destination."
4602                )
4603
4604        if isinstance(destination, str):
4605            destination = self.parseFormat.parseDecisionSpecifier(
4606                destination
4607            )
4608
4609        # If we started with a name or some other kind of decision
4610        # specifier, replace missing domain and/or zone info with info
4611        # from the current decision.
4612        if isinstance(destination, base.DecisionSpecifier):
4613            destination = base.spliceDecisionSpecifiers(
4614                destination,
4615                self.decisionTargetSpecifier()
4616            )
4617
4618        # Add an unexplored edge just before doing the return if the
4619        # named transition didn't already exist.
4620        if graph.getDestination(here, transitionName) is None:
4621            graph.addUnexploredEdge(here, transitionName)
4622
4623        # Works differently in relative mode
4624        if self.inRelativeMode:
4625            graph.replaceUnconfirmed(
4626                here,
4627                transitionName,
4628                destination,
4629                reciprocal
4630            )
4631            self.context['decision'] = graph.resolveDecision(destination)
4632            self.context['transition'] = (here, transitionName)
4633        else:
4634            destID = self.exploration.returnTo(
4635                (transitionName, outcomes),
4636                destination,
4637                reciprocal,
4638                decisionType=decisionType
4639            )
4640            self.autoFinalizeExplorationStatuses()
4641            self.context['decision'] = destID
4642            self.context['transition'] = (here, transitionName)
4643
4644    def recordWarp(
4645        self,
4646        destination: base.AnyDecisionSpecifier,
4647        decisionType: base.DecisionType = 'active'
4648    ) -> None:
4649        """
4650        Records a warp to a specific destination without creating a
4651        transition. If the destination did not exist, it will be
4652        created (but only if a `base.DecisionName` or
4653        `base.DecisionSpecifier` was supplied; a destination cannot be
4654        created based on a non-existent `base.DecisionID`).
4655        A non-standard decision type may be specified.
4656
4657        If the destination already exists its zones won't be changed.
4658        However, if the destination gets created, it will be in the same
4659        domain and added to the same zones as the previous position, or
4660        to whichever zone was specified as the zone component of a
4661        `base.DecisionSpecifier`, if any.
4662
4663        Sets the current transition to `None`.
4664
4665        In relative mode, simply updates the current target decision and
4666        sets the current target transition to `None`. It will still
4667        create the destination if necessary, possibly putting it in a
4668        zone. In relative mode, the destination's exploration status is
4669        set to "noticed" (and no exploration step is created), while in
4670        normal mode, the exploration status is set to 'unknown' in the
4671        original current step, and then a new step is added which will
4672        set the status to 'exploring'.
4673
4674        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4675        """
4676        now = self.exploration.getSituation()
4677        graph = now.graph
4678
4679        if isinstance(destination, str):
4680            destination = self.parseFormat.parseDecisionSpecifier(
4681                destination
4682            )
4683
4684        destID = graph.getDecision(destination)
4685
4686        newZone: Optional[base.Zone] = base.DefaultZone
4687        here = self.currentDecisionTarget()
4688        newDomain: Optional[base.Domain] = None
4689        if here is not None:
4690            newDomain = graph.domainFor(here)
4691        if self.inRelativeMode:  # create the decision if it didn't exist
4692            if destID not in graph:  # including if it's None
4693                if isinstance(destination, base.DecisionID):
4694                    raise JournalParseError(
4695                        f"Cannot go to decision {destination} because that"
4696                        f" decision ID does not exist, and we cannot create"
4697                        f" a new decision based only on a decision ID. Use"
4698                        f" a DecisionSpecifier or DecisionName to go to a"
4699                        f" new decision that needs to be created."
4700                    )
4701                elif isinstance(destination, base.DecisionName):
4702                    newName = destination
4703                    newZone = base.DefaultZone
4704                elif isinstance(destination, base.DecisionSpecifier):
4705                    specDomain, newZone, newName = destination
4706                    if specDomain is not None:
4707                        newDomain = specDomain
4708                else:
4709                    raise JournalParseError(
4710                        f"Invalid decision specifier: {repr(destination)}."
4711                        f" The destination must be a decision ID, a"
4712                        f" decision name, or a decision specifier."
4713                    )
4714                destID = graph.addDecision(newName, domain=newDomain)
4715                if newZone == base.DefaultZone:
4716                    ctxDecision = self.context['decision']
4717                    if ctxDecision is not None:
4718                        for zp in graph.zoneParents(ctxDecision):
4719                            graph.addDecisionToZone(destID, zp)
4720                elif newZone is not None:
4721                    graph.addDecisionToZone(destID, newZone)
4722                    # TODO: If this zone is new create it & add it to
4723                    # parent zones of old level-0 zone(s)?
4724
4725                base.setExplorationStatus(
4726                    now,
4727                    destID,
4728                    'noticed',
4729                    upgradeOnly=True
4730                )
4731                # TODO: Some way to specify 'hypothesized' here instead?
4732
4733        else:
4734            # in normal mode, 'DiscreteExploration.warp' takes care of
4735            # creating the decision if needed
4736            whichFocus = None
4737            if self.context['focus'] is not None:
4738                whichFocus = (
4739                    self.context['context'],
4740                    self.context['domain'],
4741                    self.context['focus']
4742                )
4743            if destination is None:
4744                destination = destID
4745
4746            if isinstance(destination, base.DecisionSpecifier):
4747                newZone = destination.zone
4748                if destination.domain is not None:
4749                    newDomain = destination.domain
4750            else:
4751                newZone = base.DefaultZone
4752
4753            destID = self.exploration.warp(
4754                destination,
4755                domain=newDomain,
4756                zone=newZone,
4757                whichFocus=whichFocus,
4758                inCommon=self.context['context'] == 'common',
4759                decisionType=decisionType
4760            )
4761            self.autoFinalizeExplorationStatuses()
4762
4763        self.context['decision'] = destID
4764        self.context['transition'] = None
4765
4766    def recordWait(
4767        self,
4768        decisionType: base.DecisionType = 'active'
4769    ) -> None:
4770        """
4771        Records a wait step. Does not modify the current transition.
4772        A non-standard decision type may be specified.
4773
4774        Raises a `JournalParseError` in relative mode, since it wouldn't
4775        have any effect.
4776        """
4777        if self.inRelativeMode:
4778            raise JournalParseError("Can't wait in relative mode.")
4779        else:
4780            self.exploration.wait(decisionType=decisionType)
4781
4782    def recordObserveEnding(self, name: base.DecisionName) -> None:
4783        """
4784        Records the observation of an action which warps to an ending,
4785        although unlike `recordEnd` we don't use that action yet. This
4786        does NOT update the current decision, although it sets the
4787        current transition to the action it creates.
4788
4789        The action created has the same name as the ending it warps to.
4790
4791        Note that normally, we just warp to endings, so there's no need
4792        to use `recordObserveEnding`. But if there's a player-controlled
4793        option to end the game at a particular node that is noticed
4794        before it's actually taken, this is the right thing to do.
4795
4796        We set up player-initiated ending transitions as actions with a
4797        goto rather than usual transitions because endings exist in a
4798        separate domain, and are active simultaneously with normal
4799        decisions.
4800        """
4801        graph = self.exploration.getSituation().graph
4802        here = self.definiteDecisionTarget()
4803        # Add the ending decision or grab the ID of the existing ending
4804        eID = graph.endingID(name)
4805        # Create action & add goto consequence
4806        graph.addAction(here, name)
4807        graph.setConsequence(here, name, [base.effect(goto=eID)])
4808        # Set the exploration status
4809        self.exploration.setExplorationStatus(
4810            eID,
4811            'noticed',
4812            upgradeOnly=True
4813        )
4814        self.context['transition'] = (here, name)
4815        # TODO: Prevent things like adding unexplored nodes to the
4816        # an ending...
4817
4818    def recordEnd(
4819        self,
4820        name: base.DecisionName,
4821        voluntary: bool = False,
4822        decisionType: Optional[base.DecisionType] = None
4823    ) -> None:
4824        """
4825        Records an ending. If `voluntary` is `False` (the default) then
4826        this becomes a warp that activates the specified ending (which
4827        is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave
4828        the current decision).
4829
4830        If `voluntary` is `True` then we also record an action with a
4831        'goto' effect that activates the specified ending, and record an
4832        exploration step that takes that action, instead of just a warp
4833        (`recordObserveEnding` would set up such an action without
4834        taking it).
4835
4836        The specified ending decision is created if it didn't already
4837        exist. If `voluntary` is True and an action that warps to the
4838        specified ending already exists with the correct name, we will
4839        simply take that action.
4840
4841        If it created an action, it sets the current transition to the
4842        action that warps to the ending. Endings are not added to zones;
4843        otherwise it sets the current transition to None.
4844
4845        In relative mode, an ending is still added, possibly with an
4846        action that warps to it, and the current decision is set to that
4847        ending node, but the transition doesn't actually get taken.
4848
4849        If not in relative mode, sets the exploration status of the
4850        current decision to `explored` if it wasn't in the
4851        `dontFinalize` set, even though we do not deactivate that
4852        transition.
4853
4854        When `voluntary` is not set, the decision type for the warp will
4855        be 'imposed', otherwise it will be 'active'. However, if an
4856        explicit `decisionType` is specified, that will override these
4857        defaults.
4858        """
4859        graph = self.exploration.getSituation().graph
4860        here = self.definiteDecisionTarget()
4861
4862        # Add our warping action if we need to
4863        if voluntary:
4864            # If voluntary, check for an existing warp action and set
4865            # one up if we don't have one.
4866            aDest = graph.getDestination(here, name)
4867            eID = graph.getDecision(
4868                base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name)
4869            )
4870            if aDest is None:
4871                # Okay we can just create the action
4872                self.recordObserveEnding(name)
4873                # else check if the existing transition is an action
4874                # that warps to the correct ending already
4875            elif (
4876                aDest != here
4877             or eID is None
4878             or not any(
4879                    c == base.effect(goto=eID)
4880                    for c in graph.getConsequence(here, name)
4881                )
4882            ):
4883                raise JournalParseError(
4884                    f"Attempting to add voluntary ending {name!r} at"
4885                    f" decision {graph.identityOf(here)} but that"
4886                    f" decision already has an action with that name"
4887                    f" and it's not set up to warp to that ending"
4888                    f" already."
4889                )
4890
4891        # Grab ending ID (creates the decision if necessary)
4892        eID = graph.endingID(name)
4893
4894        # Update our context variables
4895        self.context['decision'] = eID
4896        if voluntary:
4897            self.context['transition'] = (here, name)
4898        else:
4899            self.context['transition'] = None
4900
4901        # Update exploration status in relative mode, or possibly take
4902        # action in normal mode
4903        if self.inRelativeMode:
4904            self.exploration.setExplorationStatus(
4905                eID,
4906                "noticed",
4907                upgradeOnly=True
4908            )
4909        else:
4910            # Either take the action we added above, or just warp
4911            if decisionType is None:
4912                decisionType = 'active' if voluntary else 'imposed'
4913            decisionType = cast(base.DecisionType, decisionType)
4914
4915            if voluntary:
4916                # Taking the action warps us to the ending
4917                self.exploration.takeAction(
4918                    name,
4919                    decisionType=decisionType
4920                )
4921            else:
4922                # We'll use a warp to get there
4923                self.exploration.warp(
4924                    base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name),
4925                    zone=None,
4926                    decisionType=decisionType
4927                )
4928                if (
4929                    here not in self.dontFinalize
4930                and (
4931                        self.exploration.getExplorationStatus(here)
4932                     == "exploring"
4933                    )
4934                ):
4935                    self.exploration.setExplorationStatus(here, "explored")
4936        # TODO: Prevent things like adding unexplored nodes to the
4937        # ending...
4938
4939    def recordMechanism(
4940        self,
4941        where: Optional[base.AnyDecisionSpecifier],
4942        name: base.MechanismName,
4943        startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE
4944    ) -> None:
4945        """
4946        Records the existence of a mechanism at the specified decision
4947        with the specified starting state (or the default starting
4948        state). Set `where` to `None` to set up a global mechanism that's
4949        not tied to any particular decision.
4950        """
4951        graph = self.exploration.getSituation().graph
4952        # TODO: a way to set up global mechanisms
4953        newID = graph.addMechanism(name, where)
4954        if startingState != base.DEFAULT_MECHANISM_STATE:
4955            self.exploration.setMechanismStateNow(newID, startingState)
4956
4957    def recordRequirement(self, req: Union[base.Requirement, str]) -> None:
4958        """
4959        Records a requirement observed on the most recently
4960        defined/taken transition. If a string is given,
4961        `ParseFormat.parseRequirement` will be used to parse it.
4962        """
4963        if isinstance(req, str):
4964            req = self.parseFormat.parseRequirement(req)
4965        target = self.currentTransitionTarget()
4966        if target is None:
4967            raise JournalParseError(
4968                "Can't set a requirement because there is no current"
4969                " transition."
4970            )
4971        graph = self.exploration.getSituation().graph
4972        graph.setTransitionRequirement(
4973            *target,
4974            req
4975        )
4976
4977    def recordReciprocalRequirement(
4978        self,
4979        req: Union[base.Requirement, str]
4980    ) -> None:
4981        """
4982        Records a requirement observed on the reciprocal of the most
4983        recently defined/taken transition. If a string is given,
4984        `ParseFormat.parseRequirement` will be used to parse it.
4985        """
4986        if isinstance(req, str):
4987            req = self.parseFormat.parseRequirement(req)
4988        target = self.currentReciprocalTarget()
4989        if target is None:
4990            raise JournalParseError(
4991                "Can't set a reciprocal requirement because there is no"
4992                " current transition or it doesn't have a reciprocal."
4993            )
4994        graph = self.exploration.getSituation().graph
4995        graph.setTransitionRequirement(*target, req)
4996
4997    def recordTransitionConsequence(
4998        self,
4999        consequence: base.Consequence
5000    ) -> None:
5001        """
5002        Records a transition consequence, which gets added to any
5003        existing consequences of the currently-relevant transition (the
5004        most-recently created or taken transition). A `JournalParseError`
5005        will be raised if there is no current transition.
5006        """
5007        target = self.currentTransitionTarget()
5008        if target is None:
5009            raise JournalParseError(
5010                "Cannot apply a consequence because there is no current"
5011                " transition."
5012            )
5013
5014        now = self.exploration.getSituation()
5015        now.graph.addConsequence(*target, consequence)
5016
5017    def recordReciprocalConsequence(
5018        self,
5019        consequence: base.Consequence
5020    ) -> None:
5021        """
5022        Like `recordTransitionConsequence` but applies the effect to the
5023        reciprocal of the current transition. Will cause a
5024        `JournalParseError` if the current transition has no reciprocal
5025        (e.g., it's an ending transition).
5026        """
5027        target = self.currentReciprocalTarget()
5028        if target is None:
5029            raise JournalParseError(
5030                "Cannot apply a reciprocal effect because there is no"
5031                " current transition, or it doesn't have a reciprocal."
5032            )
5033
5034        now = self.exploration.getSituation()
5035        now.graph.addConsequence(*target, consequence)
5036
5037    def recordAdditionalTransitionConsequence(
5038        self,
5039        consequence: base.Consequence,
5040        hideEffects: bool = True
5041    ) -> None:
5042        """
5043        Records the addition of a new consequence to the current
5044        relevant transition, while also triggering the effects of that
5045        consequence (but not the other effects of that transition, which
5046        we presume have just been applied already).
5047
5048        By default each effect added this way automatically gets the
5049        "hidden" property added to it, because the assumption is if it
5050        were a foreseeable effect, you would have added it to the
5051        transition before taking it. If you set `hideEffects` to
5052        `False`, this won't be done.
5053
5054        This modifies the current state but does not add a step to the
5055        exploration. It does NOT call `autoFinalizeExplorationStatuses`,
5056        which means that if a 'bounce' or 'goto' effect ends up making
5057        one or more decisions no-longer-active, they do NOT get their
5058        exploration statuses upgraded to 'explored'.
5059        """
5060        # Receive begin/end indices from `addConsequence` and send them
5061        # to `applyTransitionConsequence` to limit which # parts of the
5062        # expanded consequence are actually applied.
5063        currentTransition = self.currentTransitionTarget()
5064        if currentTransition is None:
5065            consRepr = self.parseFormat.unparseConsequence(consequence)
5066            raise JournalParseError(
5067                f"Can't apply an additional consequence to a transition"
5068                f" when there is no current transition. Got"
5069                f" consequence:\n{consRepr}"
5070            )
5071
5072        if hideEffects:
5073            for (index, item) in base.walkParts(consequence):
5074                if isinstance(item, dict) and 'value' in item:
5075                    assert 'hidden' in item
5076                    item = cast(base.Effect, item)
5077                    item['hidden'] = True
5078
5079        now = self.exploration.getSituation()
5080        begin, end = now.graph.addConsequence(
5081            *currentTransition,
5082            consequence
5083        )
5084        self.exploration.applyTransitionConsequence(
5085            *currentTransition,
5086            moveWhich=self.context['focus'],
5087            policy="specified",
5088            fromIndex=begin,
5089            toIndex=end
5090        )
5091        # This tracks trigger counts and obeys
5092        # charges/delays, unlike
5093        # applyExtraneousConsequence, but some effects
5094        # like 'bounce' still can't be properly applied
5095
5096    def recordTagStep(
5097        self,
5098        tag: base.Tag,
5099        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5100    ) -> None:
5101        """
5102        Records a tag to be applied to the current exploration step.
5103        """
5104        self.exploration.tagStep(tag, value)
5105
5106    def recordTagDecision(
5107        self,
5108        tag: base.Tag,
5109        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5110    ) -> None:
5111        """
5112        Records a tag to be applied to the current decision.
5113        """
5114        now = self.exploration.getSituation()
5115        now.graph.tagDecision(
5116            self.definiteDecisionTarget(),
5117            tag,
5118            value
5119        )
5120
5121    def recordTagTranstion(
5122        self,
5123        tag: base.Tag,
5124        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5125    ) -> None:
5126        """
5127        Records a tag to be applied to the most-recently-defined or
5128        -taken transition.
5129        """
5130        target = self.currentTransitionTarget()
5131        if target is None:
5132            raise JournalParseError(
5133                "Cannot tag a transition because there is no current"
5134                " transition."
5135            )
5136
5137        now = self.exploration.getSituation()
5138        now.graph.tagTransition(*target, tag, value)
5139
5140    def recordTagReciprocal(
5141        self,
5142        tag: base.Tag,
5143        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5144    ) -> None:
5145        """
5146        Records a tag to be applied to the reciprocal of the
5147        most-recently-defined or -taken transition.
5148        """
5149        target = self.currentReciprocalTarget()
5150        if target is None:
5151            raise JournalParseError(
5152                "Cannot tag a transition because there is no current"
5153                " transition."
5154            )
5155
5156        now = self.exploration.getSituation()
5157        now.graph.tagTransition(*target, tag, value)
5158
5159    def currentZoneAtLevel(self, level: int) -> base.Zone:
5160        """
5161        Returns a zone in the current graph that applies to the current
5162        decision which is at the specified hierarchy level. If there is
5163        no such zone, raises a `JournalParseError`. If there are
5164        multiple such zones, returns the zone which includes the fewest
5165        decisions, breaking ties alphabetically by zone name.
5166        """
5167        here = self.definiteDecisionTarget()
5168        graph = self.exploration.getSituation().graph
5169        ancestors = graph.zoneAncestors(here)
5170        candidates = [
5171            ancestor
5172            for ancestor in ancestors
5173            if graph.zoneHierarchyLevel(ancestor) == level
5174        ]
5175        if len(candidates) == 0:
5176            raise JournalParseError(
5177                (
5178                    f"Cannot find any level-{level} zones for the"
5179                    f" current decision {graph.identityOf(here)}. That"
5180                    f" decision is"
5181                ) + (
5182                    " in the following zones:"
5183                  + '\n'.join(
5184                        f"  level {graph.zoneHierarchyLevel(z)}: {z!r}"
5185                        for z in ancestors
5186                    )
5187                ) if len(ancestors) > 0 else (
5188                    " not in any zones."
5189                )
5190            )
5191        candidates.sort(
5192            key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone)
5193        )
5194        return candidates[0]
5195
5196    def recordTagZone(
5197        self,
5198        level: int,
5199        tag: base.Tag,
5200        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5201    ) -> None:
5202        """
5203        Records a tag to be applied to one of the zones that the current
5204        decision is in, at a specific hierarchy level. There must be at
5205        least one zone ancestor of the current decision at that hierarchy
5206        level; if there are multiple then the tag is applied to the
5207        smallest one, breaking ties by alphabetical order.
5208        """
5209        applyTo = self.currentZoneAtLevel(level)
5210        self.exploration.getSituation().graph.tagZone(applyTo, tag, value)
5211
5212    def recordAnnotateStep(
5213        self,
5214        *annotations: base.Annotation
5215    ) -> None:
5216        """
5217        Records annotations to be applied to the current exploration
5218        step.
5219        """
5220        self.exploration.annotateStep(annotations)
5221        pf = self.parseFormat
5222        now = self.exploration.getSituation()
5223        for a in annotations:
5224            if a.startswith("at:"):
5225                expects = pf.parseDecisionSpecifier(a[3:])
5226                if isinstance(expects, base.DecisionSpecifier):
5227                    if expects.domain is None and expects.zone is None:
5228                        expects = base.spliceDecisionSpecifiers(
5229                            expects,
5230                            self.decisionTargetSpecifier()
5231                        )
5232                eID = now.graph.getDecision(expects)
5233                primaryNow: Optional[base.DecisionID]
5234                if self.inRelativeMode:
5235                    primaryNow = self.definiteDecisionTarget()
5236                else:
5237                    primaryNow = now.state['primaryDecision']
5238                if eID is None:
5239                    self.warn(
5240                        f"'at' annotation expects position {expects!r}"
5241                        f" but that's not a valid decision specifier in"
5242                        f" the current graph."
5243                    )
5244                elif eID != primaryNow:
5245                    self.warn(
5246                        f"'at' annotation expects position {expects!r}"
5247                        f" which is decision"
5248                        f" {now.graph.identityOf(eID)}, but the current"
5249                        f" primary decision is"
5250                        f" {now.graph.identityOf(primaryNow)}"
5251                    )
5252            elif a.startswith("active:"):
5253                expects = pf.parseDecisionSpecifier(a[3:])
5254                eID = now.graph.getDecision(expects)
5255                atNow = base.combinedDecisionSet(now.state)
5256                if eID is None:
5257                    self.warn(
5258                        f"'active' annotation expects decision {expects!r}"
5259                        f" but that's not a valid decision specifier in"
5260                        f" the current graph."
5261                    )
5262                elif eID not in atNow:
5263                    self.warn(
5264                        f"'active' annotation expects decision {expects!r}"
5265                        f" which is {now.graph.identityOf(eID)}, but"
5266                        f" the current active position(s) is/are:"
5267                        f"\n{now.graph.namesListing(atNow)}"
5268                    )
5269            elif a.startswith("has:"):
5270                ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0]
5271                if (
5272                    isinstance(ea, tuple)
5273                and len(ea) == 2
5274                and isinstance(ea[0], base.Token)
5275                and isinstance(ea[1], base.TokenCount)
5276                ):
5277                    countNow = base.combinedTokenCount(now.state, ea[0])
5278                    if countNow != ea[1]:
5279                        self.warn(
5280                            f"'has' annotation expects {ea[1]} {ea[0]!r}"
5281                            f" token(s) but the current state has"
5282                            f" {countNow} of them."
5283                        )
5284                else:
5285                    self.warn(
5286                        f"'has' annotation expects tokens {a[4:]!r} but"
5287                        f" that's not a (token, count) pair."
5288                    )
5289            elif a.startswith("level:"):
5290                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5291                if (
5292                    isinstance(ea, tuple)
5293                and len(ea) == 3
5294                and ea[0] == 'skill'
5295                and isinstance(ea[1], base.Skill)
5296                and isinstance(ea[2], base.Level)
5297                ):
5298                    levelNow = base.getSkillLevel(now.state, ea[1])
5299                    if levelNow != ea[2]:
5300                        self.warn(
5301                            f"'level' annotation expects skill {ea[1]!r}"
5302                            f" to be at level {ea[2]} but the current"
5303                            f" level for that skill is {levelNow}."
5304                        )
5305                else:
5306                    self.warn(
5307                        f"'level' annotation expects skill {a[6:]!r} but"
5308                        f" that's not a (skill, level) pair."
5309                    )
5310            elif a.startswith("can:"):
5311                try:
5312                    req = pf.parseRequirement(a[4:])
5313                except parsing.ParseError:
5314                    self.warn(
5315                        f"'can' annotation expects requirement {a[4:]!r}"
5316                        f" but that's not parsable as a requirement."
5317                    )
5318                    req = None
5319                if req is not None:
5320                    ctx = base.genericContextForSituation(now)
5321                    if not req.satisfied(ctx):
5322                        self.warn(
5323                            f"'can' annotation expects requirement"
5324                            f" {req!r} to be satisfied but it's not in"
5325                            f" the current situation."
5326                        )
5327            elif a.startswith("state:"):
5328                ctx = base.genericContextForSituation(
5329                    now
5330                )
5331                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5332                if (
5333                    isinstance(ea, tuple)
5334                and len(ea) == 2
5335                and isinstance(ea[0], tuple)
5336                and len(ea[0]) == 4
5337                and (ea[0][0] is None or isinstance(ea[0][0], base.Domain))
5338                and (ea[0][1] is None or isinstance(ea[0][1], base.Zone))
5339                and (
5340                        ea[0][2] is None
5341                     or isinstance(ea[0][2], base.DecisionName)
5342                    )
5343                and isinstance(ea[0][3], base.MechanismName)
5344                and isinstance(ea[1], base.MechanismState)
5345                ):
5346                    mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom)
5347                    stateNow = base.stateOfMechanism(ctx, mID)
5348                    if not base.mechanismInStateOrEquivalent(
5349                        mID,
5350                        ea[1],
5351                        ctx
5352                    ):
5353                        self.warn(
5354                            f"'state' annotation expects mechanism {mID}"
5355                            f" {ea[0]!r} to be in state {ea[1]!r} but"
5356                            f" its current state is {stateNow!r} and no"
5357                            f" equivalence makes it count as being in"
5358                            f" state {ea[1]!r}."
5359                        )
5360                else:
5361                    self.warn(
5362                        f"'state' annotation expects mechanism state"
5363                        f" {a[6:]!r} but that's not a mechanism/state"
5364                        f" pair."
5365                    )
5366            elif a.startswith("exists:"):
5367                expects = pf.parseDecisionSpecifier(a[7:])
5368                try:
5369                    now.graph.resolveDecision(expects)
5370                except core.MissingDecisionError:
5371                    self.warn(
5372                        f"'exists' annotation expects decision"
5373                        f" {a[7:]!r} but that decision does not exist."
5374                    )
5375
5376    def recordAnnotateDecision(
5377        self,
5378        *annotations: base.Annotation
5379    ) -> None:
5380        """
5381        Records annotations to be applied to the current decision.
5382        """
5383        now = self.exploration.getSituation()
5384        now.graph.annotateDecision(self.definiteDecisionTarget(), annotations)
5385
5386    def recordAnnotateTranstion(
5387        self,
5388        *annotations: base.Annotation
5389    ) -> None:
5390        """
5391        Records annotations to be applied to the most-recently-defined
5392        or -taken transition.
5393        """
5394        target = self.currentTransitionTarget()
5395        if target is None:
5396            raise JournalParseError(
5397                "Cannot annotate a transition because there is no"
5398                " current transition."
5399            )
5400
5401        now = self.exploration.getSituation()
5402        now.graph.annotateTransition(*target, annotations)
5403
5404    def recordAnnotateReciprocal(
5405        self,
5406        *annotations: base.Annotation
5407    ) -> None:
5408        """
5409        Records annotations to be applied to the reciprocal of the
5410        most-recently-defined or -taken transition.
5411        """
5412        target = self.currentReciprocalTarget()
5413        if target is None:
5414            raise JournalParseError(
5415                "Cannot annotate a reciprocal because there is no"
5416                " current transition or because it doens't have a"
5417                " reciprocal."
5418            )
5419
5420        now = self.exploration.getSituation()
5421        now.graph.annotateTransition(*target, annotations)
5422
5423    def recordAnnotateZone(
5424        self,
5425        level,
5426        *annotations: base.Annotation
5427    ) -> None:
5428        """
5429        Records annotations to be applied to the zone at the specified
5430        hierarchy level which contains the current decision. If there are
5431        multiple such zones, it picks the smallest one, breaking ties
5432        alphabetically by zone name (see `currentZoneAtLevel`).
5433        """
5434        applyTo = self.currentZoneAtLevel(level)
5435        self.exploration.getSituation().graph.annotateZone(
5436            applyTo,
5437            annotations
5438        )
5439
5440    def recordContextSwap(
5441        self,
5442        targetContext: Optional[base.FocalContextName]
5443    ) -> None:
5444        """
5445        Records a swap of the active focal context, and/or a swap into
5446        "common"-context mode where all effects modify the common focal
5447        context instead of the active one. Use `None` as the argument to
5448        swap to common mode; use another specific value so swap to
5449        normal mode and set that context as the active one.
5450
5451        In relative mode, swaps the active context without adding an
5452        exploration step. Swapping into the common context never results
5453        in a new exploration step.
5454        """
5455        if targetContext is None:
5456            self.context['context'] = "common"
5457        else:
5458            self.context['context'] = "active"
5459            e = self.getExploration()
5460            if self.inRelativeMode:
5461                e.setActiveContext(targetContext)
5462            else:
5463                e.advanceSituation(('swap', targetContext))
5464
5465    def recordZone(self, level: int, zone: base.Zone) -> None:
5466        """
5467        Records a new current zone to be swapped with the zone(s) at the
5468        specified hierarchy level for the current decision target. See
5469        `core.DiscreteExploration.reZone` and
5470        `core.DecisionGraph.replaceZonesInHierarchy` for details on what
5471        exactly happens; the summary is that the zones at the specified
5472        hierarchy level are replaced with the provided zone (which is
5473        created if necessary) and their children are re-parented onto
5474        the provided zone, while that zone is also set as a child of
5475        their parents.
5476
5477        Does the same thing in relative mode as in normal mode.
5478        """
5479        self.exploration.reZone(
5480            zone,
5481            self.definiteDecisionTarget(),
5482            level
5483        )
5484
5485    def recordUnify(
5486        self,
5487        merge: base.AnyDecisionSpecifier,
5488        mergeInto: Optional[base.AnyDecisionSpecifier] = None
5489    ) -> None:
5490        """
5491        Records a unification between two decisions. This marks an
5492        observation that they are actually the same decision and it
5493        merges them. If only one decision is given the current decision
5494        is merged into that one. After the merge, the first decision (or
5495        the current decision if only one was given) will no longer
5496        exist.
5497
5498        If one of the merged decisions was the current position in a
5499        singular-focalized domain, or one of the current positions in a
5500        plural- or spreading-focalized domain, the merged decision will
5501        replace it as a current decision after the merge, and this
5502        happens even when in relative mode. The target decision is also
5503        updated if it needs to be.
5504
5505        A `TransitionCollisionError` will be raised if the two decisions
5506        have outgoing transitions that share a name.
5507
5508        Logs a `JournalParseWarning` if the two decisions were in
5509        different zones.
5510
5511        Any transitions between the two merged decisions will remain in
5512        place as actions.
5513
5514        TODO: Option for removing self-edges after the merge? Option for
5515        doing that for just effect-less edges?
5516        """
5517        if mergeInto is None:
5518            mergeInto = merge
5519            merge = self.definiteDecisionTarget()
5520
5521        if isinstance(merge, str):
5522            merge = self.parseFormat.parseDecisionSpecifier(merge)
5523
5524        if isinstance(mergeInto, str):
5525            mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto)
5526
5527        now = self.exploration.getSituation()
5528
5529        if not isinstance(merge, base.DecisionID):
5530            merge = now.graph.resolveDecision(merge)
5531
5532        merge = cast(base.DecisionID, merge)
5533
5534        now.graph.mergeDecisions(merge, mergeInto)
5535
5536        mergedID = now.graph.resolveDecision(mergeInto)
5537
5538        # Update FocalContexts & ObservationContexts as necessary
5539        self.cleanupContexts(remapped={merge: mergedID})
5540
5541    def recordUnifyTransition(self, target: base.Transition) -> None:
5542        """
5543        Records a unification between the most-recently-defined or
5544        -taken transition and the specified transition (which must be
5545        outgoing from the same decision). This marks an observation that
5546        two transitions are actually the same transition and it merges
5547        them.
5548
5549        After the merge, the target transition will still exist but the
5550        previously most-recent transition will have been deleted.
5551
5552        Their reciprocals will also be merged.
5553
5554        A `JournalParseError` is raised if there is no most-recent
5555        transition.
5556        """
5557        now = self.exploration.getSituation()
5558        graph = now.graph
5559        affected = self.currentTransitionTarget()
5560        if affected is None or affected[1] is None:
5561            raise JournalParseError(
5562                "Cannot unify transitions: there is no current"
5563                " transition."
5564            )
5565
5566        decision, transition = affected
5567
5568        # If they don't share a target, then the current transition must
5569        # lead to an unknown node, which we will dispose of
5570        destination = graph.getDestination(decision, transition)
5571        if destination is None:
5572            raise JournalParseError(
5573                f"Cannot unify transitions: transition"
5574                f" {transition!r} at decision"
5575                f" {graph.identityOf(decision)} has no destination."
5576            )
5577
5578        finalDestination = graph.getDestination(decision, target)
5579        if finalDestination is None:
5580            raise JournalParseError(
5581                f"Cannot unify transitions: transition"
5582                f" {target!r} at decision {graph.identityOf(decision)}"
5583                f" has no destination."
5584            )
5585
5586        if destination != finalDestination:
5587            if graph.isConfirmed(destination):
5588                raise JournalParseError(
5589                    f"Cannot unify transitions: destination"
5590                    f" {graph.identityOf(destination)} of transition"
5591                    f" {transition!r} at decision"
5592                    f" {graph.identityOf(decision)} is not an"
5593                    f" unconfirmed decision."
5594                )
5595            # Retarget and delete the unknown node that we abandon
5596            # TODO: Merge nodes instead?
5597            now.graph.retargetTransition(
5598                decision,
5599                transition,
5600                finalDestination
5601            )
5602            now.graph.removeDecision(destination)
5603
5604        # Now we can merge transitions
5605        now.graph.mergeTransitions(decision, transition, target)
5606
5607        # Update targets if they were merged
5608        self.cleanupContexts(
5609            remappedTransitions={
5610                (decision, transition): (decision, target)
5611            }
5612        )
5613
5614    def recordUnifyReciprocal(
5615        self,
5616        target: base.Transition
5617    ) -> None:
5618        """
5619        Records a unification between the reciprocal of the
5620        most-recently-defined or -taken transition and the specified
5621        transition, which must be outgoing from the current transition's
5622        destination. This marks an observation that two transitions are
5623        actually the same transition and it merges them, deleting the
5624        original reciprocal. Note that the current transition will also
5625        be merged with the reciprocal of the target.
5626
5627        A `JournalParseError` is raised if there is no current
5628        transition, or if it does not have a reciprocal.
5629        """
5630        now = self.exploration.getSituation()
5631        graph = now.graph
5632        affected = self.currentReciprocalTarget()
5633        if affected is None or affected[1] is None:
5634            raise JournalParseError(
5635                "Cannot unify transitions: there is no current"
5636                " transition."
5637            )
5638
5639        decision, transition = affected
5640
5641        destination = graph.destination(decision, transition)
5642        reciprocal = graph.getReciprocal(decision, transition)
5643        if reciprocal is None:
5644            raise JournalParseError(
5645                "Cannot unify reciprocal: there is no reciprocal of the"
5646                " current transition."
5647            )
5648
5649        # If they don't share a target, then the current transition must
5650        # lead to an unknown node, which we will dispose of
5651        finalDestination = graph.getDestination(destination, target)
5652        if finalDestination is None:
5653            raise JournalParseError(
5654                f"Cannot unify reciprocal: transition"
5655                f" {target!r} at decision"
5656                f" {graph.identityOf(destination)} has no destination."
5657            )
5658
5659        if decision != finalDestination:
5660            if graph.isConfirmed(decision):
5661                raise JournalParseError(
5662                    f"Cannot unify reciprocal: destination"
5663                    f" {graph.identityOf(decision)} of transition"
5664                    f" {reciprocal!r} at decision"
5665                    f" {graph.identityOf(destination)} is not an"
5666                    f" unconfirmed decision."
5667                )
5668            # Retarget and delete the unknown node that we abandon
5669            # TODO: Merge nodes instead?
5670            graph.retargetTransition(
5671                destination,
5672                reciprocal,
5673                finalDestination
5674            )
5675            graph.removeDecision(decision)
5676
5677        # Actually merge the transitions
5678        graph.mergeTransitions(destination, reciprocal, target)
5679
5680        # Update targets if they were merged
5681        self.cleanupContexts(
5682            remappedTransitions={
5683                (decision, transition): (decision, target)
5684            }
5685        )
5686
5687    def recordObviate(
5688        self,
5689        transition: base.Transition,
5690        otherDecision: base.AnyDecisionSpecifier,
5691        otherTransition: base.Transition
5692    ) -> None:
5693        """
5694        Records the obviation of a transition at another decision. This
5695        is the observation that a specific transition at the current
5696        decision is the reciprocal of a different transition at another
5697        decision which previously led to an unknown area. The difference
5698        between this and `recordReturn` is that `recordReturn` logs
5699        movement across the newly-connected transition, while this
5700        leaves the player at their original decision (and does not even
5701        add a step to the current exploration).
5702
5703        Both transitions will be created if they didn't already exist.
5704
5705        In relative mode does the same thing and doesn't move the current
5706        decision across the transition updated.
5707
5708        If the destination is unknown, it will remain unknown after this
5709        operation.
5710        """
5711        now = self.exploration.getSituation()
5712        graph = now.graph
5713        here = self.definiteDecisionTarget()
5714
5715        if isinstance(otherDecision, str):
5716            otherDecision = self.parseFormat.parseDecisionSpecifier(
5717                otherDecision
5718            )
5719
5720        # If we started with a name or some other kind of decision
5721        # specifier, replace missing domain and/or zone info with info
5722        # from the current decision.
5723        if isinstance(otherDecision, base.DecisionSpecifier):
5724            otherDecision = base.spliceDecisionSpecifiers(
5725                otherDecision,
5726                self.decisionTargetSpecifier()
5727            )
5728
5729        otherDestination = graph.getDestination(
5730            otherDecision,
5731            otherTransition
5732        )
5733        if otherDestination is not None:
5734            if graph.isConfirmed(otherDestination):
5735                raise JournalParseError(
5736                    f"Cannot obviate transition {otherTransition!r} at"
5737                    f" decision {graph.identityOf(otherDecision)}: that"
5738                    f" transition leads to decision"
5739                    f" {graph.identityOf(otherDestination)} which has"
5740                    f" already been visited."
5741                )
5742        else:
5743            # We must create the other destination
5744            graph.addUnexploredEdge(otherDecision, otherTransition)
5745
5746        destination = graph.getDestination(here, transition)
5747        if destination is not None:
5748            if graph.isConfirmed(destination):
5749                raise JournalParseError(
5750                    f"Cannot obviate using transition {transition!r} at"
5751                    f" decision {graph.identityOf(here)}: that"
5752                    f" transition leads to decision"
5753                    f" {graph.identityOf(destination)} which is not an"
5754                    f" unconfirmed decision."
5755                )
5756        else:
5757            # we need to create it
5758            graph.addUnexploredEdge(here, transition)
5759
5760        # Track exploration status of destination (because
5761        # `replaceUnconfirmed` will overwrite it but we want to preserve
5762        # it in this case.
5763        if otherDecision is not None:
5764            prevStatus = base.explorationStatusOf(now, otherDecision)
5765
5766        # Now connect the transitions and clean up the unknown nodes
5767        graph.replaceUnconfirmed(
5768            here,
5769            transition,
5770            otherDecision,
5771            otherTransition
5772        )
5773        # Restore exploration status
5774        base.setExplorationStatus(now, otherDecision, prevStatus)
5775
5776        # Update context
5777        self.context['transition'] = (here, transition)
5778
5779    def cleanupContexts(
5780        self,
5781        remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None,
5782        remappedTransitions: Optional[
5783            Dict[
5784                Tuple[base.DecisionID, base.Transition],
5785                Tuple[base.DecisionID, base.Transition]
5786            ]
5787        ] = None
5788    ) -> None:
5789        """
5790        Checks the validity of context decision and transition entries,
5791        and sets them to `None` in situations where they are no longer
5792        valid, affecting both the current and stored contexts.
5793
5794        Also updates position information in focal contexts in the
5795        current exploration step.
5796
5797        If a `remapped` dictionary is provided, decisions in the keys of
5798        that dictionary will be replaced with the corresponding value
5799        before being checked.
5800
5801        Similarly a `remappedTransitions` dicitonary may provide info on
5802        renamed transitions using (`base.DecisionID`, `base.Transition`)
5803        pairs as both keys and values.
5804        """
5805        if remapped is None:
5806            remapped = {}
5807
5808        if remappedTransitions is None:
5809            remappedTransitions = {}
5810
5811        # Fix broken position information in the current focal contexts
5812        now = self.exploration.getSituation()
5813        graph = now.graph
5814        state = now.state
5815        for ctx in (
5816            state['common'],
5817            state['contexts'][state['activeContext']]
5818        ):
5819            active = ctx['activeDecisions']
5820            for domain in active:
5821                aVal = active[domain]
5822                if isinstance(aVal, base.DecisionID):
5823                    if aVal in remapped:  # check for remap
5824                        aVal = remapped[aVal]
5825                        active[domain] = aVal
5826                    if graph.getDecision(aVal) is None: # Ultimately valid?
5827                        active[domain] = None
5828                elif isinstance(aVal, dict):
5829                    for fpName in aVal:
5830                        fpVal = aVal[fpName]
5831                        if fpVal is None:
5832                            aVal[fpName] = None
5833                        elif fpVal in remapped:  # check for remap
5834                            aVal[fpName] = remapped[fpVal]
5835                        elif graph.getDecision(fpVal) is None:  # valid?
5836                            aVal[fpName] = None
5837                elif isinstance(aVal, set):
5838                    for r in remapped:
5839                        if r in aVal:
5840                            aVal.remove(r)
5841                            aVal.add(remapped[r])
5842                    discard = []
5843                    for dID in aVal:
5844                        if graph.getDecision(dID) is None:
5845                            discard.append(dID)
5846                    for dID in discard:
5847                        aVal.remove(dID)
5848                elif aVal is not None:
5849                    raise RuntimeError(
5850                        f"Invalid active decisions for domain"
5851                        f" {repr(domain)}: {repr(aVal)}"
5852                    )
5853
5854        # Fix up our ObservationContexts
5855        fix = [self.context]
5856        if self.storedContext is not None:
5857            fix.append(self.storedContext)
5858
5859        graph = self.exploration.getSituation().graph
5860        for obsCtx in fix:
5861            cdID = obsCtx['decision']
5862            if cdID in remapped:
5863                cdID = remapped[cdID]
5864                obsCtx['decision'] = cdID
5865
5866            if cdID not in graph:
5867                obsCtx['decision'] = None
5868
5869            transition = obsCtx['transition']
5870            if transition is not None:
5871                tSourceID = transition[0]
5872                if tSourceID in remapped:
5873                    tSourceID = remapped[tSourceID]
5874                    obsCtx['transition'] = (tSourceID, transition[1])
5875
5876                if transition in remappedTransitions:
5877                    obsCtx['transition'] = remappedTransitions[transition]
5878
5879                tDestID = graph.getDestination(tSourceID, transition[1])
5880                if tDestID is None:
5881                    obsCtx['transition'] = None
5882
5883    def recordExtinguishDecision(
5884        self,
5885        target: base.AnyDecisionSpecifier
5886    ) -> None:
5887        """
5888        Records the deletion of a decision. The decision and all
5889        transitions connected to it will be removed from the current
5890        graph. Does not create a new exploration step. If the current
5891        position is deleted, the position will be set to `None`, or if
5892        we're in relative mode, the decision target will be set to
5893        `None` if it gets deleted. Likewise, all stored and/or current
5894        transitions which no longer exist are erased to `None`.
5895        """
5896        # Erase target if it's going to be removed
5897        now = self.exploration.getSituation()
5898
5899        if isinstance(target, str):
5900            target = self.parseFormat.parseDecisionSpecifier(target)
5901
5902        # TODO: Do we need to worry about the node being part of any
5903        # focal context data structures?
5904
5905        # Actually remove it
5906        now.graph.removeDecision(target)
5907
5908        # Clean up our contexts
5909        self.cleanupContexts()
5910
5911    def recordExtinguishTransition(
5912        self,
5913        source: base.AnyDecisionSpecifier,
5914        target: base.Transition,
5915        deleteReciprocal: bool = True
5916    ) -> None:
5917        """
5918        Records the deletion of a named transition coming from a
5919        specific source. The reciprocal will also be removed, unless
5920        `deleteReciprocal` is set to False. If `deleteReciprocal` is
5921        used and this results in the complete isolation of an unknown
5922        node, that node will be deleted as well. Cleans up any saved
5923        transition targets that are no longer valid by setting them to
5924        `None`. Does not create a graph step.
5925        """
5926        now = self.exploration.getSituation()
5927        graph = now.graph
5928        dest = graph.destination(source, target)
5929
5930        # Remove the transition
5931        graph.removeTransition(source, target, deleteReciprocal)
5932
5933        # Remove the old destination if it's unconfirmed and no longer
5934        # connected anywhere
5935        if (
5936            not graph.isConfirmed(dest)
5937        and len(graph.destinationsFrom(dest)) == 0
5938        ):
5939            graph.removeDecision(dest)
5940
5941        # Clean up our contexts
5942        self.cleanupContexts()
5943
5944    def recordComplicate(
5945        self,
5946        target: base.Transition,
5947        newDecision: base.DecisionName,  # TODO: Allow zones/domain here
5948        newReciprocal: Optional[base.Transition],
5949        newReciprocalReciprocal: Optional[base.Transition]
5950    ) -> base.DecisionID:
5951        """
5952        Records the complication of a transition and its reciprocal into
5953        a new decision. The old transition and its old reciprocal (if
5954        there was one) both point to the new decision. The
5955        `newReciprocal` becomes the new reciprocal of the original
5956        transition, and the `newReciprocalReciprocal` becomes the new
5957        reciprocal of the old reciprocal. Either may be set explicitly to
5958        `None` to leave the corresponding new transition without a
5959        reciprocal (but they don't default to `None`). If there was no
5960        old reciprocal, but `newReciprocalReciprocal` is specified, then
5961        that transition is created linking the new node to the old
5962        destination, without a reciprocal.
5963
5964        The current decision & transition information is not updated.
5965
5966        Returns the decision ID for the new node.
5967        """
5968        now = self.exploration.getSituation()
5969        graph = now.graph
5970        here = self.definiteDecisionTarget()
5971        domain = graph.domainFor(here)
5972
5973        oldDest = graph.destination(here, target)
5974        oldReciprocal = graph.getReciprocal(here, target)
5975
5976        # Create the new decision:
5977        newID = graph.addDecision(newDecision, domain=domain)
5978        # Note that the new decision is NOT an unknown decision
5979        # We copy the exploration status from the current decision
5980        self.exploration.setExplorationStatus(
5981            newID,
5982            self.exploration.getExplorationStatus(here)
5983        )
5984        # Copy over zone info
5985        for zp in graph.zoneParents(here):
5986            graph.addDecisionToZone(newID, zp)
5987
5988        # Retarget the transitions
5989        graph.retargetTransition(
5990            here,
5991            target,
5992            newID,
5993            swapReciprocal=False
5994        )
5995        if oldReciprocal is not None:
5996            graph.retargetTransition(
5997                oldDest,
5998                oldReciprocal,
5999                newID,
6000                swapReciprocal=False
6001            )
6002
6003        # Add a new reciprocal edge
6004        if newReciprocal is not None:
6005            graph.addTransition(newID, newReciprocal, here)
6006            graph.setReciprocal(here, target, newReciprocal)
6007
6008        # Add a new double-reciprocal edge (even if there wasn't a
6009        # reciprocal before)
6010        if newReciprocalReciprocal is not None:
6011            graph.addTransition(
6012                newID,
6013                newReciprocalReciprocal,
6014                oldDest
6015            )
6016            if oldReciprocal is not None:
6017                graph.setReciprocal(
6018                    oldDest,
6019                    oldReciprocal,
6020                    newReciprocalReciprocal
6021                )
6022
6023        return newID
6024
6025    def recordRevert(
6026        self,
6027        slot: base.SaveSlot,
6028        aspects: Set[str],
6029        decisionType: base.DecisionType = 'active'
6030    ) -> None:
6031        """
6032        Records a reversion to a previous state (possibly for only some
6033        aspects of the current state). See `base.revertedState` for the
6034        allowed values and meanings of strings in the aspects set.
6035        Uses the specified decision type, or 'active' by default.
6036
6037        Reversion counts as an exploration step.
6038
6039        This sets the current decision to the primary decision for the
6040        reverted state (which might be `None` in some cases) and sets
6041        the current transition to None.
6042        """
6043        self.exploration.revert(slot, aspects, decisionType=decisionType)
6044        newPrimary = self.exploration.getSituation().state['primaryDecision']
6045        self.context['decision'] = newPrimary
6046        self.context['transition'] = None
6047
6048    def recordFulfills(
6049        self,
6050        requirement: Union[str, base.Requirement],
6051        fulfilled: Union[
6052            base.Capability,
6053            Tuple[base.MechanismID, base.MechanismState]
6054        ]
6055    ) -> None:
6056        """
6057        Records the observation that a certain requirement fulfills the
6058        same role as (i.e., is equivalent to) a specific capability, or a
6059        specific mechanism being in a specific state. Transitions that
6060        require that capability or mechanism state will count as
6061        traversable even if that capability is not obtained or that
6062        mechanism is in another state, as long as the requirement for the
6063        fulfillment is satisfied. If multiple equivalences are
6064        established, any one of them being satisfied will count as that
6065        capability being obtained (or the mechanism being in the
6066        specified state). Note that if a circular dependency is created,
6067        the capability or mechanism (unless actually obtained or in the
6068        target state) will be considered as not being obtained (or in the
6069        target state) during recursive checks.
6070        """
6071        if isinstance(requirement, str):
6072            requirement = self.parseFormat.parseRequirement(requirement)
6073
6074        self.getExploration().getSituation().graph.addEquivalence(
6075            requirement,
6076            fulfilled
6077        )
6078
6079    def recordFocusOn(
6080        self,
6081        newFocalPoint: base.FocalPointName,
6082        inDomain: Optional[base.Domain] = None,
6083        inCommon: bool = False
6084    ):
6085        """
6086        Records a swap to a new focal point, setting that focal point as
6087        the active focal point in the observer's current domain, or in
6088        the specified domain if one is specified.
6089
6090        A `JournalParseError` is raised if the current/specified domain
6091        does not have plural focalization. If it doesn't have a focal
6092        point with that name, then one is created and positioned at the
6093        observer's current decision (which must be in the appropriate
6094        domain).
6095
6096        If `inCommon` is set to `True` (default is `False`) then the
6097        changes will be applied to the common context instead of the
6098        active context.
6099
6100        Note that this does *not* make the target domain active; use
6101        `recordDomainFocus` for that if you need to.
6102        """
6103        if inDomain is None:
6104            inDomain = self.context['domain']
6105
6106        if inCommon:
6107            ctx = self.getExploration().getCommonContext()
6108        else:
6109            ctx = self.getExploration().getActiveContext()
6110
6111        if ctx['focalization'].get('domain') != 'plural':
6112            raise JournalParseError(
6113                f"Domain {inDomain!r} does not exist or does not have"
6114                f" plural focalization, so we can't set a focal point"
6115                f" in it."
6116            )
6117
6118        focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {})
6119        if not isinstance(focalPointMap, dict):
6120            raise RuntimeError(
6121                f"Plural-focalized domain {inDomain!r} has"
6122                f" non-dictionary active"
6123                f" decisions:\n{repr(focalPointMap)}"
6124            )
6125
6126        if newFocalPoint not in focalPointMap:
6127            focalPointMap[newFocalPoint] = self.context['decision']
6128
6129        self.context['focus'] = newFocalPoint
6130        self.context['decision'] = focalPointMap[newFocalPoint]
6131
6132    def recordDomainUnfocus(
6133        self,
6134        domain: base.Domain,
6135        inCommon: bool = False
6136    ):
6137        """
6138        Records a domain losing focus. Does not raise an error if the
6139        target domain was not active (in that case, it doesn't need to
6140        do anything).
6141
6142        If `inCommon` is set to `True` (default is `False`) then the
6143        domain changes will be applied to the common context instead of
6144        the active context.
6145        """
6146        if inCommon:
6147            ctx = self.getExploration().getCommonContext()
6148        else:
6149            ctx = self.getExploration().getActiveContext()
6150
6151        try:
6152            ctx['activeDomains'].remove(domain)
6153        except KeyError:
6154            pass
6155
6156    def recordDomainFocus(
6157        self,
6158        domain: base.Domain,
6159        exclusive: bool = False,
6160        inCommon: bool = False
6161    ):
6162        """
6163        Records a domain gaining focus, activating that domain in the
6164        current focal context and setting it as the observer's current
6165        domain. If the domain named doesn't exist yet, it will be
6166        created first (with default focalization) and then focused.
6167
6168        If `exclusive` is set to `True` (default is `False`) then all
6169        other active domains will be deactivated.
6170
6171        If `inCommon` is set to `True` (default is `False`) then the
6172        domain changes will be applied to the common context instead of
6173        the active context.
6174        """
6175        if inCommon:
6176            ctx = self.getExploration().getCommonContext()
6177        else:
6178            ctx = self.getExploration().getActiveContext()
6179
6180        if exclusive:
6181            ctx['activeDomains'] = set()
6182
6183        if domain not in ctx['focalization']:
6184            self.recordNewDomain(domain, inCommon=inCommon)
6185        else:
6186            ctx['activeDomains'].add(domain)
6187
6188        self.context['domain'] = domain
6189
6190    def recordNewDomain(
6191        self,
6192        domain: base.Domain,
6193        focalization: base.DomainFocalization = "singular",
6194        inCommon: bool = False
6195    ):
6196        """
6197        Records a new domain, setting it up with the specified
6198        focalization. Sets that domain as an active domain and as the
6199        journal's current domain so that subsequent entries will create
6200        decisions in that domain. However, it does not activate any
6201        decisions within that domain.
6202
6203        Raises a `JournalParseError` if the specified domain already
6204        exists.
6205
6206        If `inCommon` is set to `True` (default is `False`) then the new
6207        domain will be made active in the common context instead of the
6208        active context.
6209        """
6210        if inCommon:
6211            ctx = self.getExploration().getCommonContext()
6212        else:
6213            ctx = self.getExploration().getActiveContext()
6214
6215        if domain in ctx['focalization']:
6216            raise JournalParseError(
6217                f"Cannot create domain {domain!r}: that domain already"
6218                f" exists."
6219            )
6220
6221        ctx['focalization'][domain] = focalization
6222        ctx['activeDecisions'][domain] = None
6223        ctx['activeDomains'].add(domain)
6224        self.context['domain'] = domain
6225
6226    def relative(
6227        self,
6228        where: Optional[base.AnyDecisionSpecifier] = None,
6229        transition: Optional[base.Transition] = None,
6230    ) -> None:
6231        """
6232        Enters 'relative mode' where the exploration ceases to add new
6233        steps but edits can still be performed on the current graph. This
6234        also changes the current decision/transition settings so that
6235        edits can be applied anywhere. It can accept 0, 1, or 2
6236        arguments. With 0 arguments, it simply enters relative mode but
6237        maintains the current position as the target decision and the
6238        last-taken or last-created transition as the target transition
6239        (note that that transition usually originates at a different
6240        decision). With 1 argument, it sets the target decision to the
6241        decision named, and sets the target transition to None. With 2
6242        arguments, it sets the target decision to the decision named, and
6243        the target transition to the transition named, which must
6244        originate at that target decision. If the first argument is None,
6245        the current decision is used.
6246
6247        If given the name of a decision which does not yet exist, it will
6248        create that decision in the current graph, disconnected from the
6249        rest of the graph. In that case, it is an error to also supply a
6250        transition to target (you can use other commands once in relative
6251        mode to build more transitions and decisions out from the
6252        newly-created decision).
6253
6254        When called in relative mode, it updates the current position
6255        and/or decision, or if called with no arguments, it exits
6256        relative mode. When exiting relative mode, the current decision
6257        is set back to the graph's current position, and the current
6258        transition is set to whatever it was before relative mode was
6259        entered.
6260
6261        Raises a `TypeError` if a transition is specified without
6262        specifying a decision. Raises a `ValueError` if given no
6263        arguments and the exploration does not have a current position.
6264        Also raises a `ValueError` if told to target a specific
6265        transition which does not exist.
6266
6267        TODO: Example here!
6268        """
6269        # TODO: Not this?
6270        if where is None:
6271            if transition is None and self.inRelativeMode:
6272                # If we're in relative mode, cancel it
6273                self.inRelativeMode = False
6274
6275                # Here we restore saved sate
6276                if self.storedContext is None:
6277                    raise RuntimeError(
6278                        "No stored context despite being in relative"
6279                        "mode."
6280                    )
6281                self.context = self.storedContext
6282                self.storedContext = None
6283
6284            else:
6285                # Enter or stay in relative mode and set up the current
6286                # decision/transition as the targets
6287
6288                # Ensure relative mode
6289                self.inRelativeMode = True
6290
6291                # Store state
6292                self.storedContext = self.context
6293                where = self.storedContext['decision']
6294                if where is None:
6295                    raise ValueError(
6296                        "Cannot enter relative mode at the current"
6297                        " position because there is no current"
6298                        " position."
6299                    )
6300
6301                self.context = observationContext(
6302                    context=self.storedContext['context'],
6303                    domain=self.storedContext['domain'],
6304                    focus=self.storedContext['focus'],
6305                    decision=where,
6306                    transition=(
6307                        None
6308                        if transition is None
6309                        else (where, transition)
6310                    )
6311                )
6312
6313        else: # we have at least a decision to target
6314            # If we're entering relative mode instead of just changing
6315            # focus, we need to set up the current transition if no
6316            # transition was specified.
6317            entering: Optional[
6318                Tuple[
6319                    base.ContextSpecifier,
6320                    base.Domain,
6321                    Optional[base.FocalPointName]
6322                ]
6323            ] = None
6324            if not self.inRelativeMode:
6325                # We'll be entering relative mode, so store state
6326                entering = (
6327                    self.context['context'],
6328                    self.context['domain'],
6329                    self.context['focus']
6330                )
6331                self.storedContext = self.context
6332                if transition is None:
6333                    oldTransitionPair = self.context['transition']
6334                    if oldTransitionPair is not None:
6335                        oldBase, oldTransition = oldTransitionPair
6336                        if oldBase == where:
6337                            transition = oldTransition
6338
6339            # Enter (or stay in) relative mode
6340            self.inRelativeMode = True
6341
6342            now = self.exploration.getSituation()
6343            whereID: Optional[base.DecisionID]
6344            whereSpec: Optional[base.DecisionSpecifier] = None
6345            if isinstance(where, str):
6346                where = self.parseFormat.parseDecisionSpecifier(where)
6347                # might turn it into a DecisionID
6348
6349            if isinstance(where, base.DecisionID):
6350                whereID = where
6351            elif isinstance(where, base.DecisionSpecifier):
6352                # Add in current zone + domain info if those things
6353                # aren't explicit
6354                if self.currentDecisionTarget() is not None:
6355                    where = base.spliceDecisionSpecifiers(
6356                        where,
6357                        self.decisionTargetSpecifier()
6358                    )
6359                elif where.domain is None:
6360                    # Splice in current domain if needed
6361                    where = base.DecisionSpecifier(
6362                        domain=self.context['domain'],
6363                        zone=where.zone,
6364                        name=where.name
6365                    )
6366                whereID = now.graph.getDecision(where)  # might be None
6367                whereSpec = where
6368            else:
6369                raise TypeError(f"Invalid decision specifier: {where!r}")
6370
6371            # Create a new decision if necessary
6372            if whereID is None:
6373                if transition is not None:
6374                    raise TypeError(
6375                        f"Cannot specify a target transition when"
6376                        f" entering relative mode at previously"
6377                        f" non-existent decision"
6378                        f" {now.graph.identityOf(where)}."
6379                    )
6380                assert whereSpec is not None
6381                whereID = now.graph.addDecision(
6382                    whereSpec.name,
6383                    domain=whereSpec.domain
6384                )
6385                if whereSpec.zone is not None:
6386                    now.graph.addDecisionToZone(whereID, whereSpec.zone)
6387
6388            # Create the new context if we're entering relative mode
6389            if entering is not None:
6390                self.context = observationContext(
6391                    context=entering[0],
6392                    domain=entering[1],
6393                    focus=entering[2],
6394                    decision=whereID,
6395                    transition=(
6396                        None
6397                        if transition is None
6398                        else (whereID, transition)
6399                    )
6400                )
6401
6402            # Target the specified decision
6403            self.context['decision'] = whereID
6404
6405            # Target the specified transition
6406            if transition is not None:
6407                self.context['transition'] = (whereID, transition)
6408                if now.graph.getDestination(where, transition) is None:
6409                    raise ValueError(
6410                        f"Cannot target transition {transition!r} at"
6411                        f" decision {now.graph.identityOf(where)}:"
6412                        f" there is no such transition."
6413                    )
6414            # otherwise leave self.context['transition'] alone
6415
6416
6417#--------------------#
6418# Shortcut Functions #
6419#--------------------#
6420
6421def convertJournal(
6422    journal: str,
6423    fmt: Optional[JournalParseFormat] = None
6424) -> core.DiscreteExploration:
6425    """
6426    Converts a journal in text format into a `core.DiscreteExploration`
6427    object, using a fresh `JournalObserver`. An optional `ParseFormat`
6428    may be specified if the journal doesn't follow the default parse
6429    format.
6430    """
6431    obs = JournalObserver(fmt)
6432    obs.observe(journal)
6433    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: Optional[base.Zone] = base.DefaultZone
4149        newName: Optional[base.DecisionName]
4150
4151        # if a destination is specified, we need to check that it's not
4152        # an already-existing decision
4153        connectBack: bool = False  # are we connecting to a known decision?
4154        if destination is not None:
4155            # If it's not an ID, splice in current node info:
4156            if isinstance(destination, base.DecisionName):
4157                destination = base.DecisionSpecifier(None, None, destination)
4158            if isinstance(destination, base.DecisionSpecifier):
4159                destination = base.spliceDecisionSpecifiers(
4160                    destination,
4161                    self.decisionTargetSpecifier()
4162                )
4163            exists = graph.getDecision(destination)
4164            # if the specified decision doesn't exist; great. We'll
4165            # create it below
4166            if exists is not None:
4167                # If it does exist, we may have a problem. 'return' must
4168                # be used instead of 'explore' to connect to an existing
4169                # visited decision. But let's see if we really have a
4170                # conflict?
4171                otherZones = set(
4172                    z
4173                    for z in graph.zoneParents(exists)
4174                    if graph.zoneHierarchyLevel(z) == 0
4175                )
4176                currentZones = set(
4177                    z
4178                    for z in graph.zoneParents(here)
4179                    if graph.zoneHierarchyLevel(z) == 0
4180                )
4181                if (
4182                    len(otherZones & currentZones) != 0
4183                 or (
4184                        len(otherZones) == 0
4185                    and len(currentZones) == 0
4186                    )
4187                ):
4188                    if self.exploration.hasBeenVisited(exists):
4189                        # A decision by this name exists and shares at
4190                        # least one level-0 zone with the current
4191                        # decision. That means that 'return' should have
4192                        # been used.
4193                        raise JournalParseError(
4194                            f"Destiation {destination} is invalid"
4195                            f" because that decision has already been"
4196                            f" visited in the current zone. Use"
4197                            f" 'return' to record a new connection to"
4198                            f" an already-visisted decision."
4199                        )
4200                    else:
4201                        connectBack = True
4202                else:
4203                    connectBack = True
4204                # Otherwise, we can continue; the DefaultZone setting
4205                # already in place will prevail below
4206
4207        # Figure out domain & zone info for new destination
4208        if isinstance(destination, base.DecisionSpecifier):
4209            # Use current decision's domain by default
4210            if destination.domain is not None:
4211                newDomain = destination.domain
4212            else:
4213                newDomain = graph.domainFor(here)
4214
4215            # Use specified zone if there is one, else leave it as
4216            # DefaultZone to inherit zone(s) from the current decision.
4217            if destination.zone is not None:
4218                newZone = destination.zone
4219
4220            newName = destination.name
4221            # TODO: Some way to specify non-zone placement in explore?
4222
4223        elif isinstance(destination, base.DecisionID):
4224            if connectBack:
4225                newDomain = graph.domainFor(here)
4226                newZone = None
4227                newName = None
4228            else:
4229                raise JournalParseError(
4230                    f"You cannot use a decision ID when specifying a"
4231                    f" new name for an exploration destination (got:"
4232                    f" {repr(destination)})"
4233                )
4234
4235        elif isinstance(destination, base.DecisionName):
4236            newDomain = None
4237            newZone = base.DefaultZone
4238            newName = destination
4239
4240        else:  # must be None
4241            assert destination is None
4242            newDomain = None
4243            newZone = base.DefaultZone
4244            newName = None
4245
4246        if leadsTo is None:
4247            if newName is None and not connectBack:
4248                raise JournalParseError(
4249                    f"Transition {transition!r} at decision"
4250                    f" {graph.identityOf(here)} does not already exist,"
4251                    f" so a destination name must be provided."
4252                )
4253            else:
4254                graph.addUnexploredEdge(
4255                    here,
4256                    transitionName,
4257                    toDomain=newDomain  # None is the default anyways
4258                )
4259                # Zone info only added in next step
4260        elif newName is None:
4261            # TODO: Generalize this... ?
4262            currentName = graph.nameFor(leadsTo)
4263            if currentName.startswith('_u.'):
4264                raise JournalParseError(
4265                    f"Destination {graph.identityOf(leadsTo)} from"
4266                    f" decision {graph.identityOf(here)} via transition"
4267                    f" {transition!r} must be named when explored,"
4268                    f" because its current name is a placeholder."
4269                )
4270            else:
4271                newName = currentName
4272
4273        # TODO: Check for incompatible domain/zone in destination
4274        # specifier?
4275
4276        if self.inRelativeMode:
4277            if connectBack:  # connect to existing unconfirmed decision
4278                assert exists is not None
4279                graph.replaceUnconfirmed(
4280                    here,
4281                    transitionName,
4282                    exists,
4283                    reciprocal
4284                )  # we assume zones are already in place here
4285                self.exploration.setExplorationStatus(
4286                    exists,
4287                    'noticed',
4288                    upgradeOnly=True
4289                )
4290            else:  # connect to a new decision
4291                graph.replaceUnconfirmed(
4292                    here,
4293                    transitionName,
4294                    newName,
4295                    reciprocal,
4296                    placeInZone=newZone,
4297                    forceNew=True
4298                )
4299                destID = graph.destination(here, transitionName)
4300                self.exploration.setExplorationStatus(
4301                    destID,
4302                    'noticed',
4303                    upgradeOnly=True
4304                )
4305            self.context['decision'] = graph.destination(
4306                here,
4307                transitionName
4308            )
4309            self.context['transition'] = (here, transitionName)
4310        else:
4311            if connectBack:  # to a known but unvisited decision
4312                destID = self.exploration.explore(
4313                    (transitionName, outcomes),
4314                    exists,
4315                    reciprocal,
4316                    zone=newZone,
4317                    decisionType=decisionType
4318                )
4319            else:  # to an entirely new decision
4320                destID = self.exploration.explore(
4321                    (transitionName, outcomes),
4322                    newName,
4323                    reciprocal,
4324                    zone=newZone,
4325                    decisionType=decisionType
4326                )
4327            self.context['decision'] = destID
4328            self.context['transition'] = (here, transitionName)
4329            self.autoFinalizeExplorationStatuses()
4330
4331    def recordRetrace(
4332        self,
4333        transition: base.AnyTransition,
4334        decisionType: base.DecisionType = 'active',
4335        isAction: Optional[bool] = None
4336    ) -> None:
4337        """
4338        Records retracing a transition which leads to a known
4339        destination. A non-default decision type can be specified. If
4340        `isAction` is True or False, the transition must be (or must not
4341        be) an action (i.e., a transition whose destination is the same
4342        as its source). If `isAction` is left as `None` (the default)
4343        then either normal or action transitions can be retraced.
4344
4345        Sets the current transition to the transition taken.
4346
4347        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4348
4349        In relative mode, simply sets the current transition target to
4350        the transition taken and sets the current decision target to its
4351        destination (it does not apply transition effects).
4352        """
4353        here = self.definiteDecisionTarget()
4354
4355        transitionName, outcomes = base.nameAndOutcomes(transition)
4356
4357        graph = self.exploration.getSituation().graph
4358        destination = graph.getDestination(here, transitionName)
4359        if destination is None:
4360            valid = graph.destinationsListing(graph.destinationsFrom(here))
4361            raise JournalParseError(
4362                f"Cannot retrace transition {transitionName!r} from"
4363                f" decision {graph.identityOf(here)}: that transition"
4364                f" does not exist. Destinations available are:"
4365                f"\n{valid}"
4366            )
4367        if isAction is True and destination != here:
4368            raise JournalParseError(
4369                f"Cannot retrace transition {transitionName!r} from"
4370                f" decision {graph.identityOf(here)}: that transition"
4371                f" leads to {graph.identityOf(destination)} but you"
4372                f" specified that an existing action should be retraced,"
4373                f" not a normal transition. Use `recordAction` instead"
4374                f" to record a new action (including converting an"
4375                f" unconfirmed transition into an action). Leave"
4376                f" `isAction` unspeicfied or set it to `False` to"
4377                f" retrace a normal transition."
4378            )
4379        elif isAction is False and destination == here:
4380            raise JournalParseError(
4381                f"Cannot retrace transition {transitionName!r} from"
4382                f" decision {graph.identityOf(here)}: that transition"
4383                f" leads back to {graph.identityOf(destination)} but you"
4384                f" specified that an outgoing transition should be"
4385                f" retraced, not an action. Use `recordAction` instead"
4386                f" to record a new action (which must not have the same"
4387                f" name as any outgoing transition). Leave `isAction`"
4388                f" unspeicfied or set it to `True` to retrace an action."
4389            )
4390
4391        if not self.inRelativeMode:
4392            destID = self.exploration.retrace(
4393                (transitionName, outcomes),
4394                decisionType=decisionType
4395            )
4396            self.autoFinalizeExplorationStatuses()
4397        self.context['decision'] = destID
4398        self.context['transition'] = (here, transitionName)
4399
4400    def recordAction(
4401        self,
4402        action: base.AnyTransition,
4403        decisionType: base.DecisionType = 'active'
4404    ) -> None:
4405        """
4406        Records a new action taken at the current decision. A
4407        non-standard decision type may be specified. If a transition of
4408        that name already existed, it will be converted into an action
4409        assuming that its destination is unexplored and has no
4410        connections yet, and that its reciprocal also has no special
4411        properties yet. If those assumptions do not hold, a
4412        `JournalParseError` will be raised under the assumption that the
4413        name collision was an accident, not intentional, since the
4414        destination and reciprocal are deleted in the process of
4415        converting a normal transition into an action.
4416
4417        This cannot be used to re-triggger an existing action, use
4418        'retrace' for that.
4419
4420        In relative mode, the action is created (or the transition is
4421        converted into an action) but effects are not applied.
4422
4423        Although this does not usually change which decisions are
4424        active, it still calls `autoFinalizeExplorationStatuses` unless
4425        in relative mode.
4426
4427        Example:
4428
4429        >>> o = JournalObserver()
4430        >>> e = o.getExploration()
4431        >>> o.recordStart('start')
4432        >>> o.recordObserve('transition')
4433        >>> e.effectiveCapabilities()['capabilities']
4434        set()
4435        >>> o.recordObserveAction('action')
4436        >>> o.recordTransitionConsequence([base.effect(gain="capability")])
4437        >>> o.recordRetrace('action', isAction=True)
4438        >>> e.effectiveCapabilities()['capabilities']
4439        {'capability'}
4440        >>> o.recordAction('another') # add effects after...
4441        >>> effect = base.effect(lose="capability")
4442        >>> # This applies the effect and then adds it to the
4443        >>> # transition, since we already took the transition
4444        >>> o.recordAdditionalTransitionConsequence([effect])
4445        >>> e.effectiveCapabilities()['capabilities']
4446        set()
4447        >>> len(e)
4448        4
4449        >>> e.getActiveDecisions(0)
4450        set()
4451        >>> e.getActiveDecisions(1)
4452        {0}
4453        >>> e.getActiveDecisions(2)
4454        {0}
4455        >>> e.getActiveDecisions(3)
4456        {0}
4457        >>> e.getSituation(0).action
4458        ('start', 0, 0, 'main', None, None, None)
4459        >>> e.getSituation(1).action
4460        ('take', 'active', 0, ('action', []))
4461        >>> e.getSituation(2).action
4462        ('take', 'active', 0, ('another', []))
4463        """
4464        here = self.definiteDecisionTarget()
4465
4466        actionName, outcomes = base.nameAndOutcomes(action)
4467
4468        # Check if the transition already exists
4469        now = self.exploration.getSituation()
4470        graph = now.graph
4471        hereIdent = graph.identityOf(here)
4472        destinations = graph.destinationsFrom(here)
4473
4474        # A transition going somewhere else
4475        if actionName in destinations:
4476            if destinations[actionName] == here:
4477                raise JournalParseError(
4478                    f"Action {actionName!r} already exists as an action"
4479                    f" at decision {hereIdent!r}. Use 'retrace' to"
4480                    " re-activate an existing action."
4481                )
4482            else:
4483                destination = destinations[actionName]
4484                reciprocal = graph.getReciprocal(here, actionName)
4485                # To replace a transition with an action, the transition
4486                # may only have outgoing properties. Otherwise we assume
4487                # it's an error to name the action after a transition
4488                # which was intended to be a real transition.
4489                if (
4490                    graph.isConfirmed(destination)
4491                 or self.exploration.hasBeenVisited(destination)
4492                 or cast(int, graph.degree(destination)) > 2
4493                    # TODO: Fix MultiDigraph type stubs...
4494                ):
4495                    raise JournalParseError(
4496                        f"Action {actionName!r} has the same name as"
4497                        f" outgoing transition {actionName!r} at"
4498                        f" decision {hereIdent!r}. We cannot turn that"
4499                        f" transition into an action since its"
4500                        f" destination is already explored or has been"
4501                        f" connected to."
4502                    )
4503                if (
4504                    reciprocal is not None
4505                and graph.getTransitionProperties(
4506                        destination,
4507                        reciprocal
4508                    ) != {
4509                        'requirement': base.ReqNothing(),
4510                        'effects': [],
4511                        'tags': {},
4512                        'annotations': []
4513                    }
4514                ):
4515                    raise JournalParseError(
4516                        f"Action {actionName!r} has the same name as"
4517                        f" outgoing transition {actionName!r} at"
4518                        f" decision {hereIdent!r}. We cannot turn that"
4519                        f" transition into an action since its"
4520                        f" reciprocal has custom properties."
4521                    )
4522
4523                if (
4524                    graph.decisionAnnotations(destination) != []
4525                 or graph.decisionTags(destination) != {'unknown': 1}
4526                ):
4527                    raise JournalParseError(
4528                        f"Action {actionName!r} has the same name as"
4529                        f" outgoing transition {actionName!r} at"
4530                        f" decision {hereIdent!r}. We cannot turn that"
4531                        f" transition into an action since its"
4532                        f" destination has tags and/or annotations."
4533                    )
4534
4535                # If we get here, re-target the transition, and then
4536                # destroy the old destination along with the old
4537                # reciprocal edge.
4538                graph.retargetTransition(
4539                    here,
4540                    actionName,
4541                    here,
4542                    swapReciprocal=False
4543                )
4544                graph.removeDecision(destination)
4545
4546        # This will either take the existing action OR create it if
4547        # necessary
4548        if self.inRelativeMode:
4549            if actionName not in destinations:
4550                graph.addAction(here, actionName)
4551        else:
4552            destID = self.exploration.takeAction(
4553                (actionName, outcomes),
4554                fromDecision=here,
4555                decisionType=decisionType
4556            )
4557            self.autoFinalizeExplorationStatuses()
4558            self.context['decision'] = destID
4559        self.context['transition'] = (here, actionName)
4560
4561    def recordReturn(
4562        self,
4563        transition: base.AnyTransition,
4564        destination: Optional[base.AnyDecisionSpecifier] = None,
4565        reciprocal: Optional[base.Transition] = None,
4566        decisionType: base.DecisionType = 'active'
4567    ) -> None:
4568        """
4569        Records an exploration which leads back to a
4570        previously-encountered decision. If a reciprocal is specified,
4571        we connect to that transition as our reciprocal (it must have
4572        led to an unknown area or not have existed) or if not, we make a
4573        new connection with an automatic reciprocal name.
4574        A non-standard decision type may be specified.
4575
4576        If no destination is specified, then the destination of the
4577        transition must already exist.
4578
4579        If the specified transition does not exist, it will be created.
4580
4581        Sets the current transition to the transition taken.
4582
4583        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4584
4585        In relative mode, does the same stuff but doesn't apply any
4586        transition effects.
4587        """
4588        here = self.definiteDecisionTarget()
4589        now = self.exploration.getSituation()
4590        graph = now.graph
4591
4592        transitionName, outcomes = base.nameAndOutcomes(transition)
4593
4594        if destination is None:
4595            destination = graph.getDestination(here, transitionName)
4596            if destination is None:
4597                raise JournalParseError(
4598                    f"Cannot 'return' across transition"
4599                    f" {transitionName!r} from decision"
4600                    f" {graph.identityOf(here)} without specifying a"
4601                    f" destination, because that transition does not"
4602                    f" already have a destination."
4603                )
4604
4605        if isinstance(destination, str):
4606            destination = self.parseFormat.parseDecisionSpecifier(
4607                destination
4608            )
4609
4610        # If we started with a name or some other kind of decision
4611        # specifier, replace missing domain and/or zone info with info
4612        # from the current decision.
4613        if isinstance(destination, base.DecisionSpecifier):
4614            destination = base.spliceDecisionSpecifiers(
4615                destination,
4616                self.decisionTargetSpecifier()
4617            )
4618
4619        # Add an unexplored edge just before doing the return if the
4620        # named transition didn't already exist.
4621        if graph.getDestination(here, transitionName) is None:
4622            graph.addUnexploredEdge(here, transitionName)
4623
4624        # Works differently in relative mode
4625        if self.inRelativeMode:
4626            graph.replaceUnconfirmed(
4627                here,
4628                transitionName,
4629                destination,
4630                reciprocal
4631            )
4632            self.context['decision'] = graph.resolveDecision(destination)
4633            self.context['transition'] = (here, transitionName)
4634        else:
4635            destID = self.exploration.returnTo(
4636                (transitionName, outcomes),
4637                destination,
4638                reciprocal,
4639                decisionType=decisionType
4640            )
4641            self.autoFinalizeExplorationStatuses()
4642            self.context['decision'] = destID
4643            self.context['transition'] = (here, transitionName)
4644
4645    def recordWarp(
4646        self,
4647        destination: base.AnyDecisionSpecifier,
4648        decisionType: base.DecisionType = 'active'
4649    ) -> None:
4650        """
4651        Records a warp to a specific destination without creating a
4652        transition. If the destination did not exist, it will be
4653        created (but only if a `base.DecisionName` or
4654        `base.DecisionSpecifier` was supplied; a destination cannot be
4655        created based on a non-existent `base.DecisionID`).
4656        A non-standard decision type may be specified.
4657
4658        If the destination already exists its zones won't be changed.
4659        However, if the destination gets created, it will be in the same
4660        domain and added to the same zones as the previous position, or
4661        to whichever zone was specified as the zone component of a
4662        `base.DecisionSpecifier`, if any.
4663
4664        Sets the current transition to `None`.
4665
4666        In relative mode, simply updates the current target decision and
4667        sets the current target transition to `None`. It will still
4668        create the destination if necessary, possibly putting it in a
4669        zone. In relative mode, the destination's exploration status is
4670        set to "noticed" (and no exploration step is created), while in
4671        normal mode, the exploration status is set to 'unknown' in the
4672        original current step, and then a new step is added which will
4673        set the status to 'exploring'.
4674
4675        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4676        """
4677        now = self.exploration.getSituation()
4678        graph = now.graph
4679
4680        if isinstance(destination, str):
4681            destination = self.parseFormat.parseDecisionSpecifier(
4682                destination
4683            )
4684
4685        destID = graph.getDecision(destination)
4686
4687        newZone: Optional[base.Zone] = base.DefaultZone
4688        here = self.currentDecisionTarget()
4689        newDomain: Optional[base.Domain] = None
4690        if here is not None:
4691            newDomain = graph.domainFor(here)
4692        if self.inRelativeMode:  # create the decision if it didn't exist
4693            if destID not in graph:  # including if it's None
4694                if isinstance(destination, base.DecisionID):
4695                    raise JournalParseError(
4696                        f"Cannot go to decision {destination} because that"
4697                        f" decision ID does not exist, and we cannot create"
4698                        f" a new decision based only on a decision ID. Use"
4699                        f" a DecisionSpecifier or DecisionName to go to a"
4700                        f" new decision that needs to be created."
4701                    )
4702                elif isinstance(destination, base.DecisionName):
4703                    newName = destination
4704                    newZone = base.DefaultZone
4705                elif isinstance(destination, base.DecisionSpecifier):
4706                    specDomain, newZone, newName = destination
4707                    if specDomain is not None:
4708                        newDomain = specDomain
4709                else:
4710                    raise JournalParseError(
4711                        f"Invalid decision specifier: {repr(destination)}."
4712                        f" The destination must be a decision ID, a"
4713                        f" decision name, or a decision specifier."
4714                    )
4715                destID = graph.addDecision(newName, domain=newDomain)
4716                if newZone == base.DefaultZone:
4717                    ctxDecision = self.context['decision']
4718                    if ctxDecision is not None:
4719                        for zp in graph.zoneParents(ctxDecision):
4720                            graph.addDecisionToZone(destID, zp)
4721                elif newZone is not None:
4722                    graph.addDecisionToZone(destID, newZone)
4723                    # TODO: If this zone is new create it & add it to
4724                    # parent zones of old level-0 zone(s)?
4725
4726                base.setExplorationStatus(
4727                    now,
4728                    destID,
4729                    'noticed',
4730                    upgradeOnly=True
4731                )
4732                # TODO: Some way to specify 'hypothesized' here instead?
4733
4734        else:
4735            # in normal mode, 'DiscreteExploration.warp' takes care of
4736            # creating the decision if needed
4737            whichFocus = None
4738            if self.context['focus'] is not None:
4739                whichFocus = (
4740                    self.context['context'],
4741                    self.context['domain'],
4742                    self.context['focus']
4743                )
4744            if destination is None:
4745                destination = destID
4746
4747            if isinstance(destination, base.DecisionSpecifier):
4748                newZone = destination.zone
4749                if destination.domain is not None:
4750                    newDomain = destination.domain
4751            else:
4752                newZone = base.DefaultZone
4753
4754            destID = self.exploration.warp(
4755                destination,
4756                domain=newDomain,
4757                zone=newZone,
4758                whichFocus=whichFocus,
4759                inCommon=self.context['context'] == 'common',
4760                decisionType=decisionType
4761            )
4762            self.autoFinalizeExplorationStatuses()
4763
4764        self.context['decision'] = destID
4765        self.context['transition'] = None
4766
4767    def recordWait(
4768        self,
4769        decisionType: base.DecisionType = 'active'
4770    ) -> None:
4771        """
4772        Records a wait step. Does not modify the current transition.
4773        A non-standard decision type may be specified.
4774
4775        Raises a `JournalParseError` in relative mode, since it wouldn't
4776        have any effect.
4777        """
4778        if self.inRelativeMode:
4779            raise JournalParseError("Can't wait in relative mode.")
4780        else:
4781            self.exploration.wait(decisionType=decisionType)
4782
4783    def recordObserveEnding(self, name: base.DecisionName) -> None:
4784        """
4785        Records the observation of an action which warps to an ending,
4786        although unlike `recordEnd` we don't use that action yet. This
4787        does NOT update the current decision, although it sets the
4788        current transition to the action it creates.
4789
4790        The action created has the same name as the ending it warps to.
4791
4792        Note that normally, we just warp to endings, so there's no need
4793        to use `recordObserveEnding`. But if there's a player-controlled
4794        option to end the game at a particular node that is noticed
4795        before it's actually taken, this is the right thing to do.
4796
4797        We set up player-initiated ending transitions as actions with a
4798        goto rather than usual transitions because endings exist in a
4799        separate domain, and are active simultaneously with normal
4800        decisions.
4801        """
4802        graph = self.exploration.getSituation().graph
4803        here = self.definiteDecisionTarget()
4804        # Add the ending decision or grab the ID of the existing ending
4805        eID = graph.endingID(name)
4806        # Create action & add goto consequence
4807        graph.addAction(here, name)
4808        graph.setConsequence(here, name, [base.effect(goto=eID)])
4809        # Set the exploration status
4810        self.exploration.setExplorationStatus(
4811            eID,
4812            'noticed',
4813            upgradeOnly=True
4814        )
4815        self.context['transition'] = (here, name)
4816        # TODO: Prevent things like adding unexplored nodes to the
4817        # an ending...
4818
4819    def recordEnd(
4820        self,
4821        name: base.DecisionName,
4822        voluntary: bool = False,
4823        decisionType: Optional[base.DecisionType] = None
4824    ) -> None:
4825        """
4826        Records an ending. If `voluntary` is `False` (the default) then
4827        this becomes a warp that activates the specified ending (which
4828        is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave
4829        the current decision).
4830
4831        If `voluntary` is `True` then we also record an action with a
4832        'goto' effect that activates the specified ending, and record an
4833        exploration step that takes that action, instead of just a warp
4834        (`recordObserveEnding` would set up such an action without
4835        taking it).
4836
4837        The specified ending decision is created if it didn't already
4838        exist. If `voluntary` is True and an action that warps to the
4839        specified ending already exists with the correct name, we will
4840        simply take that action.
4841
4842        If it created an action, it sets the current transition to the
4843        action that warps to the ending. Endings are not added to zones;
4844        otherwise it sets the current transition to None.
4845
4846        In relative mode, an ending is still added, possibly with an
4847        action that warps to it, and the current decision is set to that
4848        ending node, but the transition doesn't actually get taken.
4849
4850        If not in relative mode, sets the exploration status of the
4851        current decision to `explored` if it wasn't in the
4852        `dontFinalize` set, even though we do not deactivate that
4853        transition.
4854
4855        When `voluntary` is not set, the decision type for the warp will
4856        be 'imposed', otherwise it will be 'active'. However, if an
4857        explicit `decisionType` is specified, that will override these
4858        defaults.
4859        """
4860        graph = self.exploration.getSituation().graph
4861        here = self.definiteDecisionTarget()
4862
4863        # Add our warping action if we need to
4864        if voluntary:
4865            # If voluntary, check for an existing warp action and set
4866            # one up if we don't have one.
4867            aDest = graph.getDestination(here, name)
4868            eID = graph.getDecision(
4869                base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name)
4870            )
4871            if aDest is None:
4872                # Okay we can just create the action
4873                self.recordObserveEnding(name)
4874                # else check if the existing transition is an action
4875                # that warps to the correct ending already
4876            elif (
4877                aDest != here
4878             or eID is None
4879             or not any(
4880                    c == base.effect(goto=eID)
4881                    for c in graph.getConsequence(here, name)
4882                )
4883            ):
4884                raise JournalParseError(
4885                    f"Attempting to add voluntary ending {name!r} at"
4886                    f" decision {graph.identityOf(here)} but that"
4887                    f" decision already has an action with that name"
4888                    f" and it's not set up to warp to that ending"
4889                    f" already."
4890                )
4891
4892        # Grab ending ID (creates the decision if necessary)
4893        eID = graph.endingID(name)
4894
4895        # Update our context variables
4896        self.context['decision'] = eID
4897        if voluntary:
4898            self.context['transition'] = (here, name)
4899        else:
4900            self.context['transition'] = None
4901
4902        # Update exploration status in relative mode, or possibly take
4903        # action in normal mode
4904        if self.inRelativeMode:
4905            self.exploration.setExplorationStatus(
4906                eID,
4907                "noticed",
4908                upgradeOnly=True
4909            )
4910        else:
4911            # Either take the action we added above, or just warp
4912            if decisionType is None:
4913                decisionType = 'active' if voluntary else 'imposed'
4914            decisionType = cast(base.DecisionType, decisionType)
4915
4916            if voluntary:
4917                # Taking the action warps us to the ending
4918                self.exploration.takeAction(
4919                    name,
4920                    decisionType=decisionType
4921                )
4922            else:
4923                # We'll use a warp to get there
4924                self.exploration.warp(
4925                    base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name),
4926                    zone=None,
4927                    decisionType=decisionType
4928                )
4929                if (
4930                    here not in self.dontFinalize
4931                and (
4932                        self.exploration.getExplorationStatus(here)
4933                     == "exploring"
4934                    )
4935                ):
4936                    self.exploration.setExplorationStatus(here, "explored")
4937        # TODO: Prevent things like adding unexplored nodes to the
4938        # ending...
4939
4940    def recordMechanism(
4941        self,
4942        where: Optional[base.AnyDecisionSpecifier],
4943        name: base.MechanismName,
4944        startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE
4945    ) -> None:
4946        """
4947        Records the existence of a mechanism at the specified decision
4948        with the specified starting state (or the default starting
4949        state). Set `where` to `None` to set up a global mechanism that's
4950        not tied to any particular decision.
4951        """
4952        graph = self.exploration.getSituation().graph
4953        # TODO: a way to set up global mechanisms
4954        newID = graph.addMechanism(name, where)
4955        if startingState != base.DEFAULT_MECHANISM_STATE:
4956            self.exploration.setMechanismStateNow(newID, startingState)
4957
4958    def recordRequirement(self, req: Union[base.Requirement, str]) -> None:
4959        """
4960        Records a requirement observed on the most recently
4961        defined/taken transition. If a string is given,
4962        `ParseFormat.parseRequirement` will be used to parse it.
4963        """
4964        if isinstance(req, str):
4965            req = self.parseFormat.parseRequirement(req)
4966        target = self.currentTransitionTarget()
4967        if target is None:
4968            raise JournalParseError(
4969                "Can't set a requirement because there is no current"
4970                " transition."
4971            )
4972        graph = self.exploration.getSituation().graph
4973        graph.setTransitionRequirement(
4974            *target,
4975            req
4976        )
4977
4978    def recordReciprocalRequirement(
4979        self,
4980        req: Union[base.Requirement, str]
4981    ) -> None:
4982        """
4983        Records a requirement observed on the reciprocal of the most
4984        recently defined/taken transition. If a string is given,
4985        `ParseFormat.parseRequirement` will be used to parse it.
4986        """
4987        if isinstance(req, str):
4988            req = self.parseFormat.parseRequirement(req)
4989        target = self.currentReciprocalTarget()
4990        if target is None:
4991            raise JournalParseError(
4992                "Can't set a reciprocal requirement because there is no"
4993                " current transition or it doesn't have a reciprocal."
4994            )
4995        graph = self.exploration.getSituation().graph
4996        graph.setTransitionRequirement(*target, req)
4997
4998    def recordTransitionConsequence(
4999        self,
5000        consequence: base.Consequence
5001    ) -> None:
5002        """
5003        Records a transition consequence, which gets added to any
5004        existing consequences of the currently-relevant transition (the
5005        most-recently created or taken transition). A `JournalParseError`
5006        will be raised if there is no current transition.
5007        """
5008        target = self.currentTransitionTarget()
5009        if target is None:
5010            raise JournalParseError(
5011                "Cannot apply a consequence because there is no current"
5012                " transition."
5013            )
5014
5015        now = self.exploration.getSituation()
5016        now.graph.addConsequence(*target, consequence)
5017
5018    def recordReciprocalConsequence(
5019        self,
5020        consequence: base.Consequence
5021    ) -> None:
5022        """
5023        Like `recordTransitionConsequence` but applies the effect to the
5024        reciprocal of the current transition. Will cause a
5025        `JournalParseError` if the current transition has no reciprocal
5026        (e.g., it's an ending transition).
5027        """
5028        target = self.currentReciprocalTarget()
5029        if target is None:
5030            raise JournalParseError(
5031                "Cannot apply a reciprocal effect because there is no"
5032                " current transition, or it doesn't have a reciprocal."
5033            )
5034
5035        now = self.exploration.getSituation()
5036        now.graph.addConsequence(*target, consequence)
5037
5038    def recordAdditionalTransitionConsequence(
5039        self,
5040        consequence: base.Consequence,
5041        hideEffects: bool = True
5042    ) -> None:
5043        """
5044        Records the addition of a new consequence to the current
5045        relevant transition, while also triggering the effects of that
5046        consequence (but not the other effects of that transition, which
5047        we presume have just been applied already).
5048
5049        By default each effect added this way automatically gets the
5050        "hidden" property added to it, because the assumption is if it
5051        were a foreseeable effect, you would have added it to the
5052        transition before taking it. If you set `hideEffects` to
5053        `False`, this won't be done.
5054
5055        This modifies the current state but does not add a step to the
5056        exploration. It does NOT call `autoFinalizeExplorationStatuses`,
5057        which means that if a 'bounce' or 'goto' effect ends up making
5058        one or more decisions no-longer-active, they do NOT get their
5059        exploration statuses upgraded to 'explored'.
5060        """
5061        # Receive begin/end indices from `addConsequence` and send them
5062        # to `applyTransitionConsequence` to limit which # parts of the
5063        # expanded consequence are actually applied.
5064        currentTransition = self.currentTransitionTarget()
5065        if currentTransition is None:
5066            consRepr = self.parseFormat.unparseConsequence(consequence)
5067            raise JournalParseError(
5068                f"Can't apply an additional consequence to a transition"
5069                f" when there is no current transition. Got"
5070                f" consequence:\n{consRepr}"
5071            )
5072
5073        if hideEffects:
5074            for (index, item) in base.walkParts(consequence):
5075                if isinstance(item, dict) and 'value' in item:
5076                    assert 'hidden' in item
5077                    item = cast(base.Effect, item)
5078                    item['hidden'] = True
5079
5080        now = self.exploration.getSituation()
5081        begin, end = now.graph.addConsequence(
5082            *currentTransition,
5083            consequence
5084        )
5085        self.exploration.applyTransitionConsequence(
5086            *currentTransition,
5087            moveWhich=self.context['focus'],
5088            policy="specified",
5089            fromIndex=begin,
5090            toIndex=end
5091        )
5092        # This tracks trigger counts and obeys
5093        # charges/delays, unlike
5094        # applyExtraneousConsequence, but some effects
5095        # like 'bounce' still can't be properly applied
5096
5097    def recordTagStep(
5098        self,
5099        tag: base.Tag,
5100        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5101    ) -> None:
5102        """
5103        Records a tag to be applied to the current exploration step.
5104        """
5105        self.exploration.tagStep(tag, value)
5106
5107    def recordTagDecision(
5108        self,
5109        tag: base.Tag,
5110        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5111    ) -> None:
5112        """
5113        Records a tag to be applied to the current decision.
5114        """
5115        now = self.exploration.getSituation()
5116        now.graph.tagDecision(
5117            self.definiteDecisionTarget(),
5118            tag,
5119            value
5120        )
5121
5122    def recordTagTranstion(
5123        self,
5124        tag: base.Tag,
5125        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5126    ) -> None:
5127        """
5128        Records a tag to be applied to the most-recently-defined or
5129        -taken transition.
5130        """
5131        target = self.currentTransitionTarget()
5132        if target is None:
5133            raise JournalParseError(
5134                "Cannot tag a transition because there is no current"
5135                " transition."
5136            )
5137
5138        now = self.exploration.getSituation()
5139        now.graph.tagTransition(*target, tag, value)
5140
5141    def recordTagReciprocal(
5142        self,
5143        tag: base.Tag,
5144        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5145    ) -> None:
5146        """
5147        Records a tag to be applied to the reciprocal of the
5148        most-recently-defined or -taken transition.
5149        """
5150        target = self.currentReciprocalTarget()
5151        if target is None:
5152            raise JournalParseError(
5153                "Cannot tag a transition because there is no current"
5154                " transition."
5155            )
5156
5157        now = self.exploration.getSituation()
5158        now.graph.tagTransition(*target, tag, value)
5159
5160    def currentZoneAtLevel(self, level: int) -> base.Zone:
5161        """
5162        Returns a zone in the current graph that applies to the current
5163        decision which is at the specified hierarchy level. If there is
5164        no such zone, raises a `JournalParseError`. If there are
5165        multiple such zones, returns the zone which includes the fewest
5166        decisions, breaking ties alphabetically by zone name.
5167        """
5168        here = self.definiteDecisionTarget()
5169        graph = self.exploration.getSituation().graph
5170        ancestors = graph.zoneAncestors(here)
5171        candidates = [
5172            ancestor
5173            for ancestor in ancestors
5174            if graph.zoneHierarchyLevel(ancestor) == level
5175        ]
5176        if len(candidates) == 0:
5177            raise JournalParseError(
5178                (
5179                    f"Cannot find any level-{level} zones for the"
5180                    f" current decision {graph.identityOf(here)}. That"
5181                    f" decision is"
5182                ) + (
5183                    " in the following zones:"
5184                  + '\n'.join(
5185                        f"  level {graph.zoneHierarchyLevel(z)}: {z!r}"
5186                        for z in ancestors
5187                    )
5188                ) if len(ancestors) > 0 else (
5189                    " not in any zones."
5190                )
5191            )
5192        candidates.sort(
5193            key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone)
5194        )
5195        return candidates[0]
5196
5197    def recordTagZone(
5198        self,
5199        level: int,
5200        tag: base.Tag,
5201        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5202    ) -> None:
5203        """
5204        Records a tag to be applied to one of the zones that the current
5205        decision is in, at a specific hierarchy level. There must be at
5206        least one zone ancestor of the current decision at that hierarchy
5207        level; if there are multiple then the tag is applied to the
5208        smallest one, breaking ties by alphabetical order.
5209        """
5210        applyTo = self.currentZoneAtLevel(level)
5211        self.exploration.getSituation().graph.tagZone(applyTo, tag, value)
5212
5213    def recordAnnotateStep(
5214        self,
5215        *annotations: base.Annotation
5216    ) -> None:
5217        """
5218        Records annotations to be applied to the current exploration
5219        step.
5220        """
5221        self.exploration.annotateStep(annotations)
5222        pf = self.parseFormat
5223        now = self.exploration.getSituation()
5224        for a in annotations:
5225            if a.startswith("at:"):
5226                expects = pf.parseDecisionSpecifier(a[3:])
5227                if isinstance(expects, base.DecisionSpecifier):
5228                    if expects.domain is None and expects.zone is None:
5229                        expects = base.spliceDecisionSpecifiers(
5230                            expects,
5231                            self.decisionTargetSpecifier()
5232                        )
5233                eID = now.graph.getDecision(expects)
5234                primaryNow: Optional[base.DecisionID]
5235                if self.inRelativeMode:
5236                    primaryNow = self.definiteDecisionTarget()
5237                else:
5238                    primaryNow = now.state['primaryDecision']
5239                if eID is None:
5240                    self.warn(
5241                        f"'at' annotation expects position {expects!r}"
5242                        f" but that's not a valid decision specifier in"
5243                        f" the current graph."
5244                    )
5245                elif eID != primaryNow:
5246                    self.warn(
5247                        f"'at' annotation expects position {expects!r}"
5248                        f" which is decision"
5249                        f" {now.graph.identityOf(eID)}, but the current"
5250                        f" primary decision is"
5251                        f" {now.graph.identityOf(primaryNow)}"
5252                    )
5253            elif a.startswith("active:"):
5254                expects = pf.parseDecisionSpecifier(a[3:])
5255                eID = now.graph.getDecision(expects)
5256                atNow = base.combinedDecisionSet(now.state)
5257                if eID is None:
5258                    self.warn(
5259                        f"'active' annotation expects decision {expects!r}"
5260                        f" but that's not a valid decision specifier in"
5261                        f" the current graph."
5262                    )
5263                elif eID not in atNow:
5264                    self.warn(
5265                        f"'active' annotation expects decision {expects!r}"
5266                        f" which is {now.graph.identityOf(eID)}, but"
5267                        f" the current active position(s) is/are:"
5268                        f"\n{now.graph.namesListing(atNow)}"
5269                    )
5270            elif a.startswith("has:"):
5271                ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0]
5272                if (
5273                    isinstance(ea, tuple)
5274                and len(ea) == 2
5275                and isinstance(ea[0], base.Token)
5276                and isinstance(ea[1], base.TokenCount)
5277                ):
5278                    countNow = base.combinedTokenCount(now.state, ea[0])
5279                    if countNow != ea[1]:
5280                        self.warn(
5281                            f"'has' annotation expects {ea[1]} {ea[0]!r}"
5282                            f" token(s) but the current state has"
5283                            f" {countNow} of them."
5284                        )
5285                else:
5286                    self.warn(
5287                        f"'has' annotation expects tokens {a[4:]!r} but"
5288                        f" that's not a (token, count) pair."
5289                    )
5290            elif a.startswith("level:"):
5291                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5292                if (
5293                    isinstance(ea, tuple)
5294                and len(ea) == 3
5295                and ea[0] == 'skill'
5296                and isinstance(ea[1], base.Skill)
5297                and isinstance(ea[2], base.Level)
5298                ):
5299                    levelNow = base.getSkillLevel(now.state, ea[1])
5300                    if levelNow != ea[2]:
5301                        self.warn(
5302                            f"'level' annotation expects skill {ea[1]!r}"
5303                            f" to be at level {ea[2]} but the current"
5304                            f" level for that skill is {levelNow}."
5305                        )
5306                else:
5307                    self.warn(
5308                        f"'level' annotation expects skill {a[6:]!r} but"
5309                        f" that's not a (skill, level) pair."
5310                    )
5311            elif a.startswith("can:"):
5312                try:
5313                    req = pf.parseRequirement(a[4:])
5314                except parsing.ParseError:
5315                    self.warn(
5316                        f"'can' annotation expects requirement {a[4:]!r}"
5317                        f" but that's not parsable as a requirement."
5318                    )
5319                    req = None
5320                if req is not None:
5321                    ctx = base.genericContextForSituation(now)
5322                    if not req.satisfied(ctx):
5323                        self.warn(
5324                            f"'can' annotation expects requirement"
5325                            f" {req!r} to be satisfied but it's not in"
5326                            f" the current situation."
5327                        )
5328            elif a.startswith("state:"):
5329                ctx = base.genericContextForSituation(
5330                    now
5331                )
5332                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5333                if (
5334                    isinstance(ea, tuple)
5335                and len(ea) == 2
5336                and isinstance(ea[0], tuple)
5337                and len(ea[0]) == 4
5338                and (ea[0][0] is None or isinstance(ea[0][0], base.Domain))
5339                and (ea[0][1] is None or isinstance(ea[0][1], base.Zone))
5340                and (
5341                        ea[0][2] is None
5342                     or isinstance(ea[0][2], base.DecisionName)
5343                    )
5344                and isinstance(ea[0][3], base.MechanismName)
5345                and isinstance(ea[1], base.MechanismState)
5346                ):
5347                    mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom)
5348                    stateNow = base.stateOfMechanism(ctx, mID)
5349                    if not base.mechanismInStateOrEquivalent(
5350                        mID,
5351                        ea[1],
5352                        ctx
5353                    ):
5354                        self.warn(
5355                            f"'state' annotation expects mechanism {mID}"
5356                            f" {ea[0]!r} to be in state {ea[1]!r} but"
5357                            f" its current state is {stateNow!r} and no"
5358                            f" equivalence makes it count as being in"
5359                            f" state {ea[1]!r}."
5360                        )
5361                else:
5362                    self.warn(
5363                        f"'state' annotation expects mechanism state"
5364                        f" {a[6:]!r} but that's not a mechanism/state"
5365                        f" pair."
5366                    )
5367            elif a.startswith("exists:"):
5368                expects = pf.parseDecisionSpecifier(a[7:])
5369                try:
5370                    now.graph.resolveDecision(expects)
5371                except core.MissingDecisionError:
5372                    self.warn(
5373                        f"'exists' annotation expects decision"
5374                        f" {a[7:]!r} but that decision does not exist."
5375                    )
5376
5377    def recordAnnotateDecision(
5378        self,
5379        *annotations: base.Annotation
5380    ) -> None:
5381        """
5382        Records annotations to be applied to the current decision.
5383        """
5384        now = self.exploration.getSituation()
5385        now.graph.annotateDecision(self.definiteDecisionTarget(), annotations)
5386
5387    def recordAnnotateTranstion(
5388        self,
5389        *annotations: base.Annotation
5390    ) -> None:
5391        """
5392        Records annotations to be applied to the most-recently-defined
5393        or -taken transition.
5394        """
5395        target = self.currentTransitionTarget()
5396        if target is None:
5397            raise JournalParseError(
5398                "Cannot annotate a transition because there is no"
5399                " current transition."
5400            )
5401
5402        now = self.exploration.getSituation()
5403        now.graph.annotateTransition(*target, annotations)
5404
5405    def recordAnnotateReciprocal(
5406        self,
5407        *annotations: base.Annotation
5408    ) -> None:
5409        """
5410        Records annotations to be applied to the reciprocal of the
5411        most-recently-defined or -taken transition.
5412        """
5413        target = self.currentReciprocalTarget()
5414        if target is None:
5415            raise JournalParseError(
5416                "Cannot annotate a reciprocal because there is no"
5417                " current transition or because it doens't have a"
5418                " reciprocal."
5419            )
5420
5421        now = self.exploration.getSituation()
5422        now.graph.annotateTransition(*target, annotations)
5423
5424    def recordAnnotateZone(
5425        self,
5426        level,
5427        *annotations: base.Annotation
5428    ) -> None:
5429        """
5430        Records annotations to be applied to the zone at the specified
5431        hierarchy level which contains the current decision. If there are
5432        multiple such zones, it picks the smallest one, breaking ties
5433        alphabetically by zone name (see `currentZoneAtLevel`).
5434        """
5435        applyTo = self.currentZoneAtLevel(level)
5436        self.exploration.getSituation().graph.annotateZone(
5437            applyTo,
5438            annotations
5439        )
5440
5441    def recordContextSwap(
5442        self,
5443        targetContext: Optional[base.FocalContextName]
5444    ) -> None:
5445        """
5446        Records a swap of the active focal context, and/or a swap into
5447        "common"-context mode where all effects modify the common focal
5448        context instead of the active one. Use `None` as the argument to
5449        swap to common mode; use another specific value so swap to
5450        normal mode and set that context as the active one.
5451
5452        In relative mode, swaps the active context without adding an
5453        exploration step. Swapping into the common context never results
5454        in a new exploration step.
5455        """
5456        if targetContext is None:
5457            self.context['context'] = "common"
5458        else:
5459            self.context['context'] = "active"
5460            e = self.getExploration()
5461            if self.inRelativeMode:
5462                e.setActiveContext(targetContext)
5463            else:
5464                e.advanceSituation(('swap', targetContext))
5465
5466    def recordZone(self, level: int, zone: base.Zone) -> None:
5467        """
5468        Records a new current zone to be swapped with the zone(s) at the
5469        specified hierarchy level for the current decision target. See
5470        `core.DiscreteExploration.reZone` and
5471        `core.DecisionGraph.replaceZonesInHierarchy` for details on what
5472        exactly happens; the summary is that the zones at the specified
5473        hierarchy level are replaced with the provided zone (which is
5474        created if necessary) and their children are re-parented onto
5475        the provided zone, while that zone is also set as a child of
5476        their parents.
5477
5478        Does the same thing in relative mode as in normal mode.
5479        """
5480        self.exploration.reZone(
5481            zone,
5482            self.definiteDecisionTarget(),
5483            level
5484        )
5485
5486    def recordUnify(
5487        self,
5488        merge: base.AnyDecisionSpecifier,
5489        mergeInto: Optional[base.AnyDecisionSpecifier] = None
5490    ) -> None:
5491        """
5492        Records a unification between two decisions. This marks an
5493        observation that they are actually the same decision and it
5494        merges them. If only one decision is given the current decision
5495        is merged into that one. After the merge, the first decision (or
5496        the current decision if only one was given) will no longer
5497        exist.
5498
5499        If one of the merged decisions was the current position in a
5500        singular-focalized domain, or one of the current positions in a
5501        plural- or spreading-focalized domain, the merged decision will
5502        replace it as a current decision after the merge, and this
5503        happens even when in relative mode. The target decision is also
5504        updated if it needs to be.
5505
5506        A `TransitionCollisionError` will be raised if the two decisions
5507        have outgoing transitions that share a name.
5508
5509        Logs a `JournalParseWarning` if the two decisions were in
5510        different zones.
5511
5512        Any transitions between the two merged decisions will remain in
5513        place as actions.
5514
5515        TODO: Option for removing self-edges after the merge? Option for
5516        doing that for just effect-less edges?
5517        """
5518        if mergeInto is None:
5519            mergeInto = merge
5520            merge = self.definiteDecisionTarget()
5521
5522        if isinstance(merge, str):
5523            merge = self.parseFormat.parseDecisionSpecifier(merge)
5524
5525        if isinstance(mergeInto, str):
5526            mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto)
5527
5528        now = self.exploration.getSituation()
5529
5530        if not isinstance(merge, base.DecisionID):
5531            merge = now.graph.resolveDecision(merge)
5532
5533        merge = cast(base.DecisionID, merge)
5534
5535        now.graph.mergeDecisions(merge, mergeInto)
5536
5537        mergedID = now.graph.resolveDecision(mergeInto)
5538
5539        # Update FocalContexts & ObservationContexts as necessary
5540        self.cleanupContexts(remapped={merge: mergedID})
5541
5542    def recordUnifyTransition(self, target: base.Transition) -> None:
5543        """
5544        Records a unification between the most-recently-defined or
5545        -taken transition and the specified transition (which must be
5546        outgoing from the same decision). This marks an observation that
5547        two transitions are actually the same transition and it merges
5548        them.
5549
5550        After the merge, the target transition will still exist but the
5551        previously most-recent transition will have been deleted.
5552
5553        Their reciprocals will also be merged.
5554
5555        A `JournalParseError` is raised if there is no most-recent
5556        transition.
5557        """
5558        now = self.exploration.getSituation()
5559        graph = now.graph
5560        affected = self.currentTransitionTarget()
5561        if affected is None or affected[1] is None:
5562            raise JournalParseError(
5563                "Cannot unify transitions: there is no current"
5564                " transition."
5565            )
5566
5567        decision, transition = affected
5568
5569        # If they don't share a target, then the current transition must
5570        # lead to an unknown node, which we will dispose of
5571        destination = graph.getDestination(decision, transition)
5572        if destination is None:
5573            raise JournalParseError(
5574                f"Cannot unify transitions: transition"
5575                f" {transition!r} at decision"
5576                f" {graph.identityOf(decision)} has no destination."
5577            )
5578
5579        finalDestination = graph.getDestination(decision, target)
5580        if finalDestination is None:
5581            raise JournalParseError(
5582                f"Cannot unify transitions: transition"
5583                f" {target!r} at decision {graph.identityOf(decision)}"
5584                f" has no destination."
5585            )
5586
5587        if destination != finalDestination:
5588            if graph.isConfirmed(destination):
5589                raise JournalParseError(
5590                    f"Cannot unify transitions: destination"
5591                    f" {graph.identityOf(destination)} of transition"
5592                    f" {transition!r} at decision"
5593                    f" {graph.identityOf(decision)} is not an"
5594                    f" unconfirmed decision."
5595                )
5596            # Retarget and delete the unknown node that we abandon
5597            # TODO: Merge nodes instead?
5598            now.graph.retargetTransition(
5599                decision,
5600                transition,
5601                finalDestination
5602            )
5603            now.graph.removeDecision(destination)
5604
5605        # Now we can merge transitions
5606        now.graph.mergeTransitions(decision, transition, target)
5607
5608        # Update targets if they were merged
5609        self.cleanupContexts(
5610            remappedTransitions={
5611                (decision, transition): (decision, target)
5612            }
5613        )
5614
5615    def recordUnifyReciprocal(
5616        self,
5617        target: base.Transition
5618    ) -> None:
5619        """
5620        Records a unification between the reciprocal of the
5621        most-recently-defined or -taken transition and the specified
5622        transition, which must be outgoing from the current transition's
5623        destination. This marks an observation that two transitions are
5624        actually the same transition and it merges them, deleting the
5625        original reciprocal. Note that the current transition will also
5626        be merged with the reciprocal of the target.
5627
5628        A `JournalParseError` is raised if there is no current
5629        transition, or if it does not have a reciprocal.
5630        """
5631        now = self.exploration.getSituation()
5632        graph = now.graph
5633        affected = self.currentReciprocalTarget()
5634        if affected is None or affected[1] is None:
5635            raise JournalParseError(
5636                "Cannot unify transitions: there is no current"
5637                " transition."
5638            )
5639
5640        decision, transition = affected
5641
5642        destination = graph.destination(decision, transition)
5643        reciprocal = graph.getReciprocal(decision, transition)
5644        if reciprocal is None:
5645            raise JournalParseError(
5646                "Cannot unify reciprocal: there is no reciprocal of the"
5647                " current transition."
5648            )
5649
5650        # If they don't share a target, then the current transition must
5651        # lead to an unknown node, which we will dispose of
5652        finalDestination = graph.getDestination(destination, target)
5653        if finalDestination is None:
5654            raise JournalParseError(
5655                f"Cannot unify reciprocal: transition"
5656                f" {target!r} at decision"
5657                f" {graph.identityOf(destination)} has no destination."
5658            )
5659
5660        if decision != finalDestination:
5661            if graph.isConfirmed(decision):
5662                raise JournalParseError(
5663                    f"Cannot unify reciprocal: destination"
5664                    f" {graph.identityOf(decision)} of transition"
5665                    f" {reciprocal!r} at decision"
5666                    f" {graph.identityOf(destination)} is not an"
5667                    f" unconfirmed decision."
5668                )
5669            # Retarget and delete the unknown node that we abandon
5670            # TODO: Merge nodes instead?
5671            graph.retargetTransition(
5672                destination,
5673                reciprocal,
5674                finalDestination
5675            )
5676            graph.removeDecision(decision)
5677
5678        # Actually merge the transitions
5679        graph.mergeTransitions(destination, reciprocal, target)
5680
5681        # Update targets if they were merged
5682        self.cleanupContexts(
5683            remappedTransitions={
5684                (decision, transition): (decision, target)
5685            }
5686        )
5687
5688    def recordObviate(
5689        self,
5690        transition: base.Transition,
5691        otherDecision: base.AnyDecisionSpecifier,
5692        otherTransition: base.Transition
5693    ) -> None:
5694        """
5695        Records the obviation of a transition at another decision. This
5696        is the observation that a specific transition at the current
5697        decision is the reciprocal of a different transition at another
5698        decision which previously led to an unknown area. The difference
5699        between this and `recordReturn` is that `recordReturn` logs
5700        movement across the newly-connected transition, while this
5701        leaves the player at their original decision (and does not even
5702        add a step to the current exploration).
5703
5704        Both transitions will be created if they didn't already exist.
5705
5706        In relative mode does the same thing and doesn't move the current
5707        decision across the transition updated.
5708
5709        If the destination is unknown, it will remain unknown after this
5710        operation.
5711        """
5712        now = self.exploration.getSituation()
5713        graph = now.graph
5714        here = self.definiteDecisionTarget()
5715
5716        if isinstance(otherDecision, str):
5717            otherDecision = self.parseFormat.parseDecisionSpecifier(
5718                otherDecision
5719            )
5720
5721        # If we started with a name or some other kind of decision
5722        # specifier, replace missing domain and/or zone info with info
5723        # from the current decision.
5724        if isinstance(otherDecision, base.DecisionSpecifier):
5725            otherDecision = base.spliceDecisionSpecifiers(
5726                otherDecision,
5727                self.decisionTargetSpecifier()
5728            )
5729
5730        otherDestination = graph.getDestination(
5731            otherDecision,
5732            otherTransition
5733        )
5734        if otherDestination is not None:
5735            if graph.isConfirmed(otherDestination):
5736                raise JournalParseError(
5737                    f"Cannot obviate transition {otherTransition!r} at"
5738                    f" decision {graph.identityOf(otherDecision)}: that"
5739                    f" transition leads to decision"
5740                    f" {graph.identityOf(otherDestination)} which has"
5741                    f" already been visited."
5742                )
5743        else:
5744            # We must create the other destination
5745            graph.addUnexploredEdge(otherDecision, otherTransition)
5746
5747        destination = graph.getDestination(here, transition)
5748        if destination is not None:
5749            if graph.isConfirmed(destination):
5750                raise JournalParseError(
5751                    f"Cannot obviate using transition {transition!r} at"
5752                    f" decision {graph.identityOf(here)}: that"
5753                    f" transition leads to decision"
5754                    f" {graph.identityOf(destination)} which is not an"
5755                    f" unconfirmed decision."
5756                )
5757        else:
5758            # we need to create it
5759            graph.addUnexploredEdge(here, transition)
5760
5761        # Track exploration status of destination (because
5762        # `replaceUnconfirmed` will overwrite it but we want to preserve
5763        # it in this case.
5764        if otherDecision is not None:
5765            prevStatus = base.explorationStatusOf(now, otherDecision)
5766
5767        # Now connect the transitions and clean up the unknown nodes
5768        graph.replaceUnconfirmed(
5769            here,
5770            transition,
5771            otherDecision,
5772            otherTransition
5773        )
5774        # Restore exploration status
5775        base.setExplorationStatus(now, otherDecision, prevStatus)
5776
5777        # Update context
5778        self.context['transition'] = (here, transition)
5779
5780    def cleanupContexts(
5781        self,
5782        remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None,
5783        remappedTransitions: Optional[
5784            Dict[
5785                Tuple[base.DecisionID, base.Transition],
5786                Tuple[base.DecisionID, base.Transition]
5787            ]
5788        ] = None
5789    ) -> None:
5790        """
5791        Checks the validity of context decision and transition entries,
5792        and sets them to `None` in situations where they are no longer
5793        valid, affecting both the current and stored contexts.
5794
5795        Also updates position information in focal contexts in the
5796        current exploration step.
5797
5798        If a `remapped` dictionary is provided, decisions in the keys of
5799        that dictionary will be replaced with the corresponding value
5800        before being checked.
5801
5802        Similarly a `remappedTransitions` dicitonary may provide info on
5803        renamed transitions using (`base.DecisionID`, `base.Transition`)
5804        pairs as both keys and values.
5805        """
5806        if remapped is None:
5807            remapped = {}
5808
5809        if remappedTransitions is None:
5810            remappedTransitions = {}
5811
5812        # Fix broken position information in the current focal contexts
5813        now = self.exploration.getSituation()
5814        graph = now.graph
5815        state = now.state
5816        for ctx in (
5817            state['common'],
5818            state['contexts'][state['activeContext']]
5819        ):
5820            active = ctx['activeDecisions']
5821            for domain in active:
5822                aVal = active[domain]
5823                if isinstance(aVal, base.DecisionID):
5824                    if aVal in remapped:  # check for remap
5825                        aVal = remapped[aVal]
5826                        active[domain] = aVal
5827                    if graph.getDecision(aVal) is None: # Ultimately valid?
5828                        active[domain] = None
5829                elif isinstance(aVal, dict):
5830                    for fpName in aVal:
5831                        fpVal = aVal[fpName]
5832                        if fpVal is None:
5833                            aVal[fpName] = None
5834                        elif fpVal in remapped:  # check for remap
5835                            aVal[fpName] = remapped[fpVal]
5836                        elif graph.getDecision(fpVal) is None:  # valid?
5837                            aVal[fpName] = None
5838                elif isinstance(aVal, set):
5839                    for r in remapped:
5840                        if r in aVal:
5841                            aVal.remove(r)
5842                            aVal.add(remapped[r])
5843                    discard = []
5844                    for dID in aVal:
5845                        if graph.getDecision(dID) is None:
5846                            discard.append(dID)
5847                    for dID in discard:
5848                        aVal.remove(dID)
5849                elif aVal is not None:
5850                    raise RuntimeError(
5851                        f"Invalid active decisions for domain"
5852                        f" {repr(domain)}: {repr(aVal)}"
5853                    )
5854
5855        # Fix up our ObservationContexts
5856        fix = [self.context]
5857        if self.storedContext is not None:
5858            fix.append(self.storedContext)
5859
5860        graph = self.exploration.getSituation().graph
5861        for obsCtx in fix:
5862            cdID = obsCtx['decision']
5863            if cdID in remapped:
5864                cdID = remapped[cdID]
5865                obsCtx['decision'] = cdID
5866
5867            if cdID not in graph:
5868                obsCtx['decision'] = None
5869
5870            transition = obsCtx['transition']
5871            if transition is not None:
5872                tSourceID = transition[0]
5873                if tSourceID in remapped:
5874                    tSourceID = remapped[tSourceID]
5875                    obsCtx['transition'] = (tSourceID, transition[1])
5876
5877                if transition in remappedTransitions:
5878                    obsCtx['transition'] = remappedTransitions[transition]
5879
5880                tDestID = graph.getDestination(tSourceID, transition[1])
5881                if tDestID is None:
5882                    obsCtx['transition'] = None
5883
5884    def recordExtinguishDecision(
5885        self,
5886        target: base.AnyDecisionSpecifier
5887    ) -> None:
5888        """
5889        Records the deletion of a decision. The decision and all
5890        transitions connected to it will be removed from the current
5891        graph. Does not create a new exploration step. If the current
5892        position is deleted, the position will be set to `None`, or if
5893        we're in relative mode, the decision target will be set to
5894        `None` if it gets deleted. Likewise, all stored and/or current
5895        transitions which no longer exist are erased to `None`.
5896        """
5897        # Erase target if it's going to be removed
5898        now = self.exploration.getSituation()
5899
5900        if isinstance(target, str):
5901            target = self.parseFormat.parseDecisionSpecifier(target)
5902
5903        # TODO: Do we need to worry about the node being part of any
5904        # focal context data structures?
5905
5906        # Actually remove it
5907        now.graph.removeDecision(target)
5908
5909        # Clean up our contexts
5910        self.cleanupContexts()
5911
5912    def recordExtinguishTransition(
5913        self,
5914        source: base.AnyDecisionSpecifier,
5915        target: base.Transition,
5916        deleteReciprocal: bool = True
5917    ) -> None:
5918        """
5919        Records the deletion of a named transition coming from a
5920        specific source. The reciprocal will also be removed, unless
5921        `deleteReciprocal` is set to False. If `deleteReciprocal` is
5922        used and this results in the complete isolation of an unknown
5923        node, that node will be deleted as well. Cleans up any saved
5924        transition targets that are no longer valid by setting them to
5925        `None`. Does not create a graph step.
5926        """
5927        now = self.exploration.getSituation()
5928        graph = now.graph
5929        dest = graph.destination(source, target)
5930
5931        # Remove the transition
5932        graph.removeTransition(source, target, deleteReciprocal)
5933
5934        # Remove the old destination if it's unconfirmed and no longer
5935        # connected anywhere
5936        if (
5937            not graph.isConfirmed(dest)
5938        and len(graph.destinationsFrom(dest)) == 0
5939        ):
5940            graph.removeDecision(dest)
5941
5942        # Clean up our contexts
5943        self.cleanupContexts()
5944
5945    def recordComplicate(
5946        self,
5947        target: base.Transition,
5948        newDecision: base.DecisionName,  # TODO: Allow zones/domain here
5949        newReciprocal: Optional[base.Transition],
5950        newReciprocalReciprocal: Optional[base.Transition]
5951    ) -> base.DecisionID:
5952        """
5953        Records the complication of a transition and its reciprocal into
5954        a new decision. The old transition and its old reciprocal (if
5955        there was one) both point to the new decision. The
5956        `newReciprocal` becomes the new reciprocal of the original
5957        transition, and the `newReciprocalReciprocal` becomes the new
5958        reciprocal of the old reciprocal. Either may be set explicitly to
5959        `None` to leave the corresponding new transition without a
5960        reciprocal (but they don't default to `None`). If there was no
5961        old reciprocal, but `newReciprocalReciprocal` is specified, then
5962        that transition is created linking the new node to the old
5963        destination, without a reciprocal.
5964
5965        The current decision & transition information is not updated.
5966
5967        Returns the decision ID for the new node.
5968        """
5969        now = self.exploration.getSituation()
5970        graph = now.graph
5971        here = self.definiteDecisionTarget()
5972        domain = graph.domainFor(here)
5973
5974        oldDest = graph.destination(here, target)
5975        oldReciprocal = graph.getReciprocal(here, target)
5976
5977        # Create the new decision:
5978        newID = graph.addDecision(newDecision, domain=domain)
5979        # Note that the new decision is NOT an unknown decision
5980        # We copy the exploration status from the current decision
5981        self.exploration.setExplorationStatus(
5982            newID,
5983            self.exploration.getExplorationStatus(here)
5984        )
5985        # Copy over zone info
5986        for zp in graph.zoneParents(here):
5987            graph.addDecisionToZone(newID, zp)
5988
5989        # Retarget the transitions
5990        graph.retargetTransition(
5991            here,
5992            target,
5993            newID,
5994            swapReciprocal=False
5995        )
5996        if oldReciprocal is not None:
5997            graph.retargetTransition(
5998                oldDest,
5999                oldReciprocal,
6000                newID,
6001                swapReciprocal=False
6002            )
6003
6004        # Add a new reciprocal edge
6005        if newReciprocal is not None:
6006            graph.addTransition(newID, newReciprocal, here)
6007            graph.setReciprocal(here, target, newReciprocal)
6008
6009        # Add a new double-reciprocal edge (even if there wasn't a
6010        # reciprocal before)
6011        if newReciprocalReciprocal is not None:
6012            graph.addTransition(
6013                newID,
6014                newReciprocalReciprocal,
6015                oldDest
6016            )
6017            if oldReciprocal is not None:
6018                graph.setReciprocal(
6019                    oldDest,
6020                    oldReciprocal,
6021                    newReciprocalReciprocal
6022                )
6023
6024        return newID
6025
6026    def recordRevert(
6027        self,
6028        slot: base.SaveSlot,
6029        aspects: Set[str],
6030        decisionType: base.DecisionType = 'active'
6031    ) -> None:
6032        """
6033        Records a reversion to a previous state (possibly for only some
6034        aspects of the current state). See `base.revertedState` for the
6035        allowed values and meanings of strings in the aspects set.
6036        Uses the specified decision type, or 'active' by default.
6037
6038        Reversion counts as an exploration step.
6039
6040        This sets the current decision to the primary decision for the
6041        reverted state (which might be `None` in some cases) and sets
6042        the current transition to None.
6043        """
6044        self.exploration.revert(slot, aspects, decisionType=decisionType)
6045        newPrimary = self.exploration.getSituation().state['primaryDecision']
6046        self.context['decision'] = newPrimary
6047        self.context['transition'] = None
6048
6049    def recordFulfills(
6050        self,
6051        requirement: Union[str, base.Requirement],
6052        fulfilled: Union[
6053            base.Capability,
6054            Tuple[base.MechanismID, base.MechanismState]
6055        ]
6056    ) -> None:
6057        """
6058        Records the observation that a certain requirement fulfills the
6059        same role as (i.e., is equivalent to) a specific capability, or a
6060        specific mechanism being in a specific state. Transitions that
6061        require that capability or mechanism state will count as
6062        traversable even if that capability is not obtained or that
6063        mechanism is in another state, as long as the requirement for the
6064        fulfillment is satisfied. If multiple equivalences are
6065        established, any one of them being satisfied will count as that
6066        capability being obtained (or the mechanism being in the
6067        specified state). Note that if a circular dependency is created,
6068        the capability or mechanism (unless actually obtained or in the
6069        target state) will be considered as not being obtained (or in the
6070        target state) during recursive checks.
6071        """
6072        if isinstance(requirement, str):
6073            requirement = self.parseFormat.parseRequirement(requirement)
6074
6075        self.getExploration().getSituation().graph.addEquivalence(
6076            requirement,
6077            fulfilled
6078        )
6079
6080    def recordFocusOn(
6081        self,
6082        newFocalPoint: base.FocalPointName,
6083        inDomain: Optional[base.Domain] = None,
6084        inCommon: bool = False
6085    ):
6086        """
6087        Records a swap to a new focal point, setting that focal point as
6088        the active focal point in the observer's current domain, or in
6089        the specified domain if one is specified.
6090
6091        A `JournalParseError` is raised if the current/specified domain
6092        does not have plural focalization. If it doesn't have a focal
6093        point with that name, then one is created and positioned at the
6094        observer's current decision (which must be in the appropriate
6095        domain).
6096
6097        If `inCommon` is set to `True` (default is `False`) then the
6098        changes will be applied to the common context instead of the
6099        active context.
6100
6101        Note that this does *not* make the target domain active; use
6102        `recordDomainFocus` for that if you need to.
6103        """
6104        if inDomain is None:
6105            inDomain = self.context['domain']
6106
6107        if inCommon:
6108            ctx = self.getExploration().getCommonContext()
6109        else:
6110            ctx = self.getExploration().getActiveContext()
6111
6112        if ctx['focalization'].get('domain') != 'plural':
6113            raise JournalParseError(
6114                f"Domain {inDomain!r} does not exist or does not have"
6115                f" plural focalization, so we can't set a focal point"
6116                f" in it."
6117            )
6118
6119        focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {})
6120        if not isinstance(focalPointMap, dict):
6121            raise RuntimeError(
6122                f"Plural-focalized domain {inDomain!r} has"
6123                f" non-dictionary active"
6124                f" decisions:\n{repr(focalPointMap)}"
6125            )
6126
6127        if newFocalPoint not in focalPointMap:
6128            focalPointMap[newFocalPoint] = self.context['decision']
6129
6130        self.context['focus'] = newFocalPoint
6131        self.context['decision'] = focalPointMap[newFocalPoint]
6132
6133    def recordDomainUnfocus(
6134        self,
6135        domain: base.Domain,
6136        inCommon: bool = False
6137    ):
6138        """
6139        Records a domain losing focus. Does not raise an error if the
6140        target domain was not active (in that case, it doesn't need to
6141        do anything).
6142
6143        If `inCommon` is set to `True` (default is `False`) then the
6144        domain changes will be applied to the common context instead of
6145        the active context.
6146        """
6147        if inCommon:
6148            ctx = self.getExploration().getCommonContext()
6149        else:
6150            ctx = self.getExploration().getActiveContext()
6151
6152        try:
6153            ctx['activeDomains'].remove(domain)
6154        except KeyError:
6155            pass
6156
6157    def recordDomainFocus(
6158        self,
6159        domain: base.Domain,
6160        exclusive: bool = False,
6161        inCommon: bool = False
6162    ):
6163        """
6164        Records a domain gaining focus, activating that domain in the
6165        current focal context and setting it as the observer's current
6166        domain. If the domain named doesn't exist yet, it will be
6167        created first (with default focalization) and then focused.
6168
6169        If `exclusive` is set to `True` (default is `False`) then all
6170        other active domains will be deactivated.
6171
6172        If `inCommon` is set to `True` (default is `False`) then the
6173        domain changes will be applied to the common context instead of
6174        the active context.
6175        """
6176        if inCommon:
6177            ctx = self.getExploration().getCommonContext()
6178        else:
6179            ctx = self.getExploration().getActiveContext()
6180
6181        if exclusive:
6182            ctx['activeDomains'] = set()
6183
6184        if domain not in ctx['focalization']:
6185            self.recordNewDomain(domain, inCommon=inCommon)
6186        else:
6187            ctx['activeDomains'].add(domain)
6188
6189        self.context['domain'] = domain
6190
6191    def recordNewDomain(
6192        self,
6193        domain: base.Domain,
6194        focalization: base.DomainFocalization = "singular",
6195        inCommon: bool = False
6196    ):
6197        """
6198        Records a new domain, setting it up with the specified
6199        focalization. Sets that domain as an active domain and as the
6200        journal's current domain so that subsequent entries will create
6201        decisions in that domain. However, it does not activate any
6202        decisions within that domain.
6203
6204        Raises a `JournalParseError` if the specified domain already
6205        exists.
6206
6207        If `inCommon` is set to `True` (default is `False`) then the new
6208        domain will be made active in the common context instead of the
6209        active context.
6210        """
6211        if inCommon:
6212            ctx = self.getExploration().getCommonContext()
6213        else:
6214            ctx = self.getExploration().getActiveContext()
6215
6216        if domain in ctx['focalization']:
6217            raise JournalParseError(
6218                f"Cannot create domain {domain!r}: that domain already"
6219                f" exists."
6220            )
6221
6222        ctx['focalization'][domain] = focalization
6223        ctx['activeDecisions'][domain] = None
6224        ctx['activeDomains'].add(domain)
6225        self.context['domain'] = domain
6226
6227    def relative(
6228        self,
6229        where: Optional[base.AnyDecisionSpecifier] = None,
6230        transition: Optional[base.Transition] = None,
6231    ) -> None:
6232        """
6233        Enters 'relative mode' where the exploration ceases to add new
6234        steps but edits can still be performed on the current graph. This
6235        also changes the current decision/transition settings so that
6236        edits can be applied anywhere. It can accept 0, 1, or 2
6237        arguments. With 0 arguments, it simply enters relative mode but
6238        maintains the current position as the target decision and the
6239        last-taken or last-created transition as the target transition
6240        (note that that transition usually originates at a different
6241        decision). With 1 argument, it sets the target decision to the
6242        decision named, and sets the target transition to None. With 2
6243        arguments, it sets the target decision to the decision named, and
6244        the target transition to the transition named, which must
6245        originate at that target decision. If the first argument is None,
6246        the current decision is used.
6247
6248        If given the name of a decision which does not yet exist, it will
6249        create that decision in the current graph, disconnected from the
6250        rest of the graph. In that case, it is an error to also supply a
6251        transition to target (you can use other commands once in relative
6252        mode to build more transitions and decisions out from the
6253        newly-created decision).
6254
6255        When called in relative mode, it updates the current position
6256        and/or decision, or if called with no arguments, it exits
6257        relative mode. When exiting relative mode, the current decision
6258        is set back to the graph's current position, and the current
6259        transition is set to whatever it was before relative mode was
6260        entered.
6261
6262        Raises a `TypeError` if a transition is specified without
6263        specifying a decision. Raises a `ValueError` if given no
6264        arguments and the exploration does not have a current position.
6265        Also raises a `ValueError` if told to target a specific
6266        transition which does not exist.
6267
6268        TODO: Example here!
6269        """
6270        # TODO: Not this?
6271        if where is None:
6272            if transition is None and self.inRelativeMode:
6273                # If we're in relative mode, cancel it
6274                self.inRelativeMode = False
6275
6276                # Here we restore saved sate
6277                if self.storedContext is None:
6278                    raise RuntimeError(
6279                        "No stored context despite being in relative"
6280                        "mode."
6281                    )
6282                self.context = self.storedContext
6283                self.storedContext = None
6284
6285            else:
6286                # Enter or stay in relative mode and set up the current
6287                # decision/transition as the targets
6288
6289                # Ensure relative mode
6290                self.inRelativeMode = True
6291
6292                # Store state
6293                self.storedContext = self.context
6294                where = self.storedContext['decision']
6295                if where is None:
6296                    raise ValueError(
6297                        "Cannot enter relative mode at the current"
6298                        " position because there is no current"
6299                        " position."
6300                    )
6301
6302                self.context = observationContext(
6303                    context=self.storedContext['context'],
6304                    domain=self.storedContext['domain'],
6305                    focus=self.storedContext['focus'],
6306                    decision=where,
6307                    transition=(
6308                        None
6309                        if transition is None
6310                        else (where, transition)
6311                    )
6312                )
6313
6314        else: # we have at least a decision to target
6315            # If we're entering relative mode instead of just changing
6316            # focus, we need to set up the current transition if no
6317            # transition was specified.
6318            entering: Optional[
6319                Tuple[
6320                    base.ContextSpecifier,
6321                    base.Domain,
6322                    Optional[base.FocalPointName]
6323                ]
6324            ] = None
6325            if not self.inRelativeMode:
6326                # We'll be entering relative mode, so store state
6327                entering = (
6328                    self.context['context'],
6329                    self.context['domain'],
6330                    self.context['focus']
6331                )
6332                self.storedContext = self.context
6333                if transition is None:
6334                    oldTransitionPair = self.context['transition']
6335                    if oldTransitionPair is not None:
6336                        oldBase, oldTransition = oldTransitionPair
6337                        if oldBase == where:
6338                            transition = oldTransition
6339
6340            # Enter (or stay in) relative mode
6341            self.inRelativeMode = True
6342
6343            now = self.exploration.getSituation()
6344            whereID: Optional[base.DecisionID]
6345            whereSpec: Optional[base.DecisionSpecifier] = None
6346            if isinstance(where, str):
6347                where = self.parseFormat.parseDecisionSpecifier(where)
6348                # might turn it into a DecisionID
6349
6350            if isinstance(where, base.DecisionID):
6351                whereID = where
6352            elif isinstance(where, base.DecisionSpecifier):
6353                # Add in current zone + domain info if those things
6354                # aren't explicit
6355                if self.currentDecisionTarget() is not None:
6356                    where = base.spliceDecisionSpecifiers(
6357                        where,
6358                        self.decisionTargetSpecifier()
6359                    )
6360                elif where.domain is None:
6361                    # Splice in current domain if needed
6362                    where = base.DecisionSpecifier(
6363                        domain=self.context['domain'],
6364                        zone=where.zone,
6365                        name=where.name
6366                    )
6367                whereID = now.graph.getDecision(where)  # might be None
6368                whereSpec = where
6369            else:
6370                raise TypeError(f"Invalid decision specifier: {where!r}")
6371
6372            # Create a new decision if necessary
6373            if whereID is None:
6374                if transition is not None:
6375                    raise TypeError(
6376                        f"Cannot specify a target transition when"
6377                        f" entering relative mode at previously"
6378                        f" non-existent decision"
6379                        f" {now.graph.identityOf(where)}."
6380                    )
6381                assert whereSpec is not None
6382                whereID = now.graph.addDecision(
6383                    whereSpec.name,
6384                    domain=whereSpec.domain
6385                )
6386                if whereSpec.zone is not None:
6387                    now.graph.addDecisionToZone(whereID, whereSpec.zone)
6388
6389            # Create the new context if we're entering relative mode
6390            if entering is not None:
6391                self.context = observationContext(
6392                    context=entering[0],
6393                    domain=entering[1],
6394                    focus=entering[2],
6395                    decision=whereID,
6396                    transition=(
6397                        None
6398                        if transition is None
6399                        else (whereID, transition)
6400                    )
6401                )
6402
6403            # Target the specified decision
6404            self.context['decision'] = whereID
6405
6406            # Target the specified transition
6407            if transition is not None:
6408                self.context['transition'] = (whereID, transition)
6409                if now.graph.getDestination(where, transition) is None:
6410                    raise ValueError(
6411                        f"Cannot target transition {transition!r} at"
6412                        f" decision {now.graph.identityOf(where)}:"
6413                        f" there is no such transition."
6414                    )
6415            # 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: Optional[base.Zone] = base.DefaultZone
4149        newName: Optional[base.DecisionName]
4150
4151        # if a destination is specified, we need to check that it's not
4152        # an already-existing decision
4153        connectBack: bool = False  # are we connecting to a known decision?
4154        if destination is not None:
4155            # If it's not an ID, splice in current node info:
4156            if isinstance(destination, base.DecisionName):
4157                destination = base.DecisionSpecifier(None, None, destination)
4158            if isinstance(destination, base.DecisionSpecifier):
4159                destination = base.spliceDecisionSpecifiers(
4160                    destination,
4161                    self.decisionTargetSpecifier()
4162                )
4163            exists = graph.getDecision(destination)
4164            # if the specified decision doesn't exist; great. We'll
4165            # create it below
4166            if exists is not None:
4167                # If it does exist, we may have a problem. 'return' must
4168                # be used instead of 'explore' to connect to an existing
4169                # visited decision. But let's see if we really have a
4170                # conflict?
4171                otherZones = set(
4172                    z
4173                    for z in graph.zoneParents(exists)
4174                    if graph.zoneHierarchyLevel(z) == 0
4175                )
4176                currentZones = set(
4177                    z
4178                    for z in graph.zoneParents(here)
4179                    if graph.zoneHierarchyLevel(z) == 0
4180                )
4181                if (
4182                    len(otherZones & currentZones) != 0
4183                 or (
4184                        len(otherZones) == 0
4185                    and len(currentZones) == 0
4186                    )
4187                ):
4188                    if self.exploration.hasBeenVisited(exists):
4189                        # A decision by this name exists and shares at
4190                        # least one level-0 zone with the current
4191                        # decision. That means that 'return' should have
4192                        # been used.
4193                        raise JournalParseError(
4194                            f"Destiation {destination} is invalid"
4195                            f" because that decision has already been"
4196                            f" visited in the current zone. Use"
4197                            f" 'return' to record a new connection to"
4198                            f" an already-visisted decision."
4199                        )
4200                    else:
4201                        connectBack = True
4202                else:
4203                    connectBack = True
4204                # Otherwise, we can continue; the DefaultZone setting
4205                # already in place will prevail below
4206
4207        # Figure out domain & zone info for new destination
4208        if isinstance(destination, base.DecisionSpecifier):
4209            # Use current decision's domain by default
4210            if destination.domain is not None:
4211                newDomain = destination.domain
4212            else:
4213                newDomain = graph.domainFor(here)
4214
4215            # Use specified zone if there is one, else leave it as
4216            # DefaultZone to inherit zone(s) from the current decision.
4217            if destination.zone is not None:
4218                newZone = destination.zone
4219
4220            newName = destination.name
4221            # TODO: Some way to specify non-zone placement in explore?
4222
4223        elif isinstance(destination, base.DecisionID):
4224            if connectBack:
4225                newDomain = graph.domainFor(here)
4226                newZone = None
4227                newName = None
4228            else:
4229                raise JournalParseError(
4230                    f"You cannot use a decision ID when specifying a"
4231                    f" new name for an exploration destination (got:"
4232                    f" {repr(destination)})"
4233                )
4234
4235        elif isinstance(destination, base.DecisionName):
4236            newDomain = None
4237            newZone = base.DefaultZone
4238            newName = destination
4239
4240        else:  # must be None
4241            assert destination is None
4242            newDomain = None
4243            newZone = base.DefaultZone
4244            newName = None
4245
4246        if leadsTo is None:
4247            if newName is None and not connectBack:
4248                raise JournalParseError(
4249                    f"Transition {transition!r} at decision"
4250                    f" {graph.identityOf(here)} does not already exist,"
4251                    f" so a destination name must be provided."
4252                )
4253            else:
4254                graph.addUnexploredEdge(
4255                    here,
4256                    transitionName,
4257                    toDomain=newDomain  # None is the default anyways
4258                )
4259                # Zone info only added in next step
4260        elif newName is None:
4261            # TODO: Generalize this... ?
4262            currentName = graph.nameFor(leadsTo)
4263            if currentName.startswith('_u.'):
4264                raise JournalParseError(
4265                    f"Destination {graph.identityOf(leadsTo)} from"
4266                    f" decision {graph.identityOf(here)} via transition"
4267                    f" {transition!r} must be named when explored,"
4268                    f" because its current name is a placeholder."
4269                )
4270            else:
4271                newName = currentName
4272
4273        # TODO: Check for incompatible domain/zone in destination
4274        # specifier?
4275
4276        if self.inRelativeMode:
4277            if connectBack:  # connect to existing unconfirmed decision
4278                assert exists is not None
4279                graph.replaceUnconfirmed(
4280                    here,
4281                    transitionName,
4282                    exists,
4283                    reciprocal
4284                )  # we assume zones are already in place here
4285                self.exploration.setExplorationStatus(
4286                    exists,
4287                    'noticed',
4288                    upgradeOnly=True
4289                )
4290            else:  # connect to a new decision
4291                graph.replaceUnconfirmed(
4292                    here,
4293                    transitionName,
4294                    newName,
4295                    reciprocal,
4296                    placeInZone=newZone,
4297                    forceNew=True
4298                )
4299                destID = graph.destination(here, transitionName)
4300                self.exploration.setExplorationStatus(
4301                    destID,
4302                    'noticed',
4303                    upgradeOnly=True
4304                )
4305            self.context['decision'] = graph.destination(
4306                here,
4307                transitionName
4308            )
4309            self.context['transition'] = (here, transitionName)
4310        else:
4311            if connectBack:  # to a known but unvisited decision
4312                destID = self.exploration.explore(
4313                    (transitionName, outcomes),
4314                    exists,
4315                    reciprocal,
4316                    zone=newZone,
4317                    decisionType=decisionType
4318                )
4319            else:  # to an entirely new decision
4320                destID = self.exploration.explore(
4321                    (transitionName, outcomes),
4322                    newName,
4323                    reciprocal,
4324                    zone=newZone,
4325                    decisionType=decisionType
4326                )
4327            self.context['decision'] = destID
4328            self.context['transition'] = (here, transitionName)
4329            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:
4331    def recordRetrace(
4332        self,
4333        transition: base.AnyTransition,
4334        decisionType: base.DecisionType = 'active',
4335        isAction: Optional[bool] = None
4336    ) -> None:
4337        """
4338        Records retracing a transition which leads to a known
4339        destination. A non-default decision type can be specified. If
4340        `isAction` is True or False, the transition must be (or must not
4341        be) an action (i.e., a transition whose destination is the same
4342        as its source). If `isAction` is left as `None` (the default)
4343        then either normal or action transitions can be retraced.
4344
4345        Sets the current transition to the transition taken.
4346
4347        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4348
4349        In relative mode, simply sets the current transition target to
4350        the transition taken and sets the current decision target to its
4351        destination (it does not apply transition effects).
4352        """
4353        here = self.definiteDecisionTarget()
4354
4355        transitionName, outcomes = base.nameAndOutcomes(transition)
4356
4357        graph = self.exploration.getSituation().graph
4358        destination = graph.getDestination(here, transitionName)
4359        if destination is None:
4360            valid = graph.destinationsListing(graph.destinationsFrom(here))
4361            raise JournalParseError(
4362                f"Cannot retrace transition {transitionName!r} from"
4363                f" decision {graph.identityOf(here)}: that transition"
4364                f" does not exist. Destinations available are:"
4365                f"\n{valid}"
4366            )
4367        if isAction is True and destination != here:
4368            raise JournalParseError(
4369                f"Cannot retrace transition {transitionName!r} from"
4370                f" decision {graph.identityOf(here)}: that transition"
4371                f" leads to {graph.identityOf(destination)} but you"
4372                f" specified that an existing action should be retraced,"
4373                f" not a normal transition. Use `recordAction` instead"
4374                f" to record a new action (including converting an"
4375                f" unconfirmed transition into an action). Leave"
4376                f" `isAction` unspeicfied or set it to `False` to"
4377                f" retrace a normal transition."
4378            )
4379        elif isAction is False and destination == here:
4380            raise JournalParseError(
4381                f"Cannot retrace transition {transitionName!r} from"
4382                f" decision {graph.identityOf(here)}: that transition"
4383                f" leads back to {graph.identityOf(destination)} but you"
4384                f" specified that an outgoing transition should be"
4385                f" retraced, not an action. Use `recordAction` instead"
4386                f" to record a new action (which must not have the same"
4387                f" name as any outgoing transition). Leave `isAction`"
4388                f" unspeicfied or set it to `True` to retrace an action."
4389            )
4390
4391        if not self.inRelativeMode:
4392            destID = self.exploration.retrace(
4393                (transitionName, outcomes),
4394                decisionType=decisionType
4395            )
4396            self.autoFinalizeExplorationStatuses()
4397        self.context['decision'] = destID
4398        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:
4400    def recordAction(
4401        self,
4402        action: base.AnyTransition,
4403        decisionType: base.DecisionType = 'active'
4404    ) -> None:
4405        """
4406        Records a new action taken at the current decision. A
4407        non-standard decision type may be specified. If a transition of
4408        that name already existed, it will be converted into an action
4409        assuming that its destination is unexplored and has no
4410        connections yet, and that its reciprocal also has no special
4411        properties yet. If those assumptions do not hold, a
4412        `JournalParseError` will be raised under the assumption that the
4413        name collision was an accident, not intentional, since the
4414        destination and reciprocal are deleted in the process of
4415        converting a normal transition into an action.
4416
4417        This cannot be used to re-triggger an existing action, use
4418        'retrace' for that.
4419
4420        In relative mode, the action is created (or the transition is
4421        converted into an action) but effects are not applied.
4422
4423        Although this does not usually change which decisions are
4424        active, it still calls `autoFinalizeExplorationStatuses` unless
4425        in relative mode.
4426
4427        Example:
4428
4429        >>> o = JournalObserver()
4430        >>> e = o.getExploration()
4431        >>> o.recordStart('start')
4432        >>> o.recordObserve('transition')
4433        >>> e.effectiveCapabilities()['capabilities']
4434        set()
4435        >>> o.recordObserveAction('action')
4436        >>> o.recordTransitionConsequence([base.effect(gain="capability")])
4437        >>> o.recordRetrace('action', isAction=True)
4438        >>> e.effectiveCapabilities()['capabilities']
4439        {'capability'}
4440        >>> o.recordAction('another') # add effects after...
4441        >>> effect = base.effect(lose="capability")
4442        >>> # This applies the effect and then adds it to the
4443        >>> # transition, since we already took the transition
4444        >>> o.recordAdditionalTransitionConsequence([effect])
4445        >>> e.effectiveCapabilities()['capabilities']
4446        set()
4447        >>> len(e)
4448        4
4449        >>> e.getActiveDecisions(0)
4450        set()
4451        >>> e.getActiveDecisions(1)
4452        {0}
4453        >>> e.getActiveDecisions(2)
4454        {0}
4455        >>> e.getActiveDecisions(3)
4456        {0}
4457        >>> e.getSituation(0).action
4458        ('start', 0, 0, 'main', None, None, None)
4459        >>> e.getSituation(1).action
4460        ('take', 'active', 0, ('action', []))
4461        >>> e.getSituation(2).action
4462        ('take', 'active', 0, ('another', []))
4463        """
4464        here = self.definiteDecisionTarget()
4465
4466        actionName, outcomes = base.nameAndOutcomes(action)
4467
4468        # Check if the transition already exists
4469        now = self.exploration.getSituation()
4470        graph = now.graph
4471        hereIdent = graph.identityOf(here)
4472        destinations = graph.destinationsFrom(here)
4473
4474        # A transition going somewhere else
4475        if actionName in destinations:
4476            if destinations[actionName] == here:
4477                raise JournalParseError(
4478                    f"Action {actionName!r} already exists as an action"
4479                    f" at decision {hereIdent!r}. Use 'retrace' to"
4480                    " re-activate an existing action."
4481                )
4482            else:
4483                destination = destinations[actionName]
4484                reciprocal = graph.getReciprocal(here, actionName)
4485                # To replace a transition with an action, the transition
4486                # may only have outgoing properties. Otherwise we assume
4487                # it's an error to name the action after a transition
4488                # which was intended to be a real transition.
4489                if (
4490                    graph.isConfirmed(destination)
4491                 or self.exploration.hasBeenVisited(destination)
4492                 or cast(int, graph.degree(destination)) > 2
4493                    # TODO: Fix MultiDigraph type stubs...
4494                ):
4495                    raise JournalParseError(
4496                        f"Action {actionName!r} has the same name as"
4497                        f" outgoing transition {actionName!r} at"
4498                        f" decision {hereIdent!r}. We cannot turn that"
4499                        f" transition into an action since its"
4500                        f" destination is already explored or has been"
4501                        f" connected to."
4502                    )
4503                if (
4504                    reciprocal is not None
4505                and graph.getTransitionProperties(
4506                        destination,
4507                        reciprocal
4508                    ) != {
4509                        'requirement': base.ReqNothing(),
4510                        'effects': [],
4511                        'tags': {},
4512                        'annotations': []
4513                    }
4514                ):
4515                    raise JournalParseError(
4516                        f"Action {actionName!r} has the same name as"
4517                        f" outgoing transition {actionName!r} at"
4518                        f" decision {hereIdent!r}. We cannot turn that"
4519                        f" transition into an action since its"
4520                        f" reciprocal has custom properties."
4521                    )
4522
4523                if (
4524                    graph.decisionAnnotations(destination) != []
4525                 or graph.decisionTags(destination) != {'unknown': 1}
4526                ):
4527                    raise JournalParseError(
4528                        f"Action {actionName!r} has the same name as"
4529                        f" outgoing transition {actionName!r} at"
4530                        f" decision {hereIdent!r}. We cannot turn that"
4531                        f" transition into an action since its"
4532                        f" destination has tags and/or annotations."
4533                    )
4534
4535                # If we get here, re-target the transition, and then
4536                # destroy the old destination along with the old
4537                # reciprocal edge.
4538                graph.retargetTransition(
4539                    here,
4540                    actionName,
4541                    here,
4542                    swapReciprocal=False
4543                )
4544                graph.removeDecision(destination)
4545
4546        # This will either take the existing action OR create it if
4547        # necessary
4548        if self.inRelativeMode:
4549            if actionName not in destinations:
4550                graph.addAction(here, actionName)
4551        else:
4552            destID = self.exploration.takeAction(
4553                (actionName, outcomes),
4554                fromDecision=here,
4555                decisionType=decisionType
4556            )
4557            self.autoFinalizeExplorationStatuses()
4558            self.context['decision'] = destID
4559        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:
4561    def recordReturn(
4562        self,
4563        transition: base.AnyTransition,
4564        destination: Optional[base.AnyDecisionSpecifier] = None,
4565        reciprocal: Optional[base.Transition] = None,
4566        decisionType: base.DecisionType = 'active'
4567    ) -> None:
4568        """
4569        Records an exploration which leads back to a
4570        previously-encountered decision. If a reciprocal is specified,
4571        we connect to that transition as our reciprocal (it must have
4572        led to an unknown area or not have existed) or if not, we make a
4573        new connection with an automatic reciprocal name.
4574        A non-standard decision type may be specified.
4575
4576        If no destination is specified, then the destination of the
4577        transition must already exist.
4578
4579        If the specified transition does not exist, it will be created.
4580
4581        Sets the current transition to the transition taken.
4582
4583        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4584
4585        In relative mode, does the same stuff but doesn't apply any
4586        transition effects.
4587        """
4588        here = self.definiteDecisionTarget()
4589        now = self.exploration.getSituation()
4590        graph = now.graph
4591
4592        transitionName, outcomes = base.nameAndOutcomes(transition)
4593
4594        if destination is None:
4595            destination = graph.getDestination(here, transitionName)
4596            if destination is None:
4597                raise JournalParseError(
4598                    f"Cannot 'return' across transition"
4599                    f" {transitionName!r} from decision"
4600                    f" {graph.identityOf(here)} without specifying a"
4601                    f" destination, because that transition does not"
4602                    f" already have a destination."
4603                )
4604
4605        if isinstance(destination, str):
4606            destination = self.parseFormat.parseDecisionSpecifier(
4607                destination
4608            )
4609
4610        # If we started with a name or some other kind of decision
4611        # specifier, replace missing domain and/or zone info with info
4612        # from the current decision.
4613        if isinstance(destination, base.DecisionSpecifier):
4614            destination = base.spliceDecisionSpecifiers(
4615                destination,
4616                self.decisionTargetSpecifier()
4617            )
4618
4619        # Add an unexplored edge just before doing the return if the
4620        # named transition didn't already exist.
4621        if graph.getDestination(here, transitionName) is None:
4622            graph.addUnexploredEdge(here, transitionName)
4623
4624        # Works differently in relative mode
4625        if self.inRelativeMode:
4626            graph.replaceUnconfirmed(
4627                here,
4628                transitionName,
4629                destination,
4630                reciprocal
4631            )
4632            self.context['decision'] = graph.resolveDecision(destination)
4633            self.context['transition'] = (here, transitionName)
4634        else:
4635            destID = self.exploration.returnTo(
4636                (transitionName, outcomes),
4637                destination,
4638                reciprocal,
4639                decisionType=decisionType
4640            )
4641            self.autoFinalizeExplorationStatuses()
4642            self.context['decision'] = destID
4643            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:
4645    def recordWarp(
4646        self,
4647        destination: base.AnyDecisionSpecifier,
4648        decisionType: base.DecisionType = 'active'
4649    ) -> None:
4650        """
4651        Records a warp to a specific destination without creating a
4652        transition. If the destination did not exist, it will be
4653        created (but only if a `base.DecisionName` or
4654        `base.DecisionSpecifier` was supplied; a destination cannot be
4655        created based on a non-existent `base.DecisionID`).
4656        A non-standard decision type may be specified.
4657
4658        If the destination already exists its zones won't be changed.
4659        However, if the destination gets created, it will be in the same
4660        domain and added to the same zones as the previous position, or
4661        to whichever zone was specified as the zone component of a
4662        `base.DecisionSpecifier`, if any.
4663
4664        Sets the current transition to `None`.
4665
4666        In relative mode, simply updates the current target decision and
4667        sets the current target transition to `None`. It will still
4668        create the destination if necessary, possibly putting it in a
4669        zone. In relative mode, the destination's exploration status is
4670        set to "noticed" (and no exploration step is created), while in
4671        normal mode, the exploration status is set to 'unknown' in the
4672        original current step, and then a new step is added which will
4673        set the status to 'exploring'.
4674
4675        Calls `autoFinalizeExplorationStatuses` unless in relative mode.
4676        """
4677        now = self.exploration.getSituation()
4678        graph = now.graph
4679
4680        if isinstance(destination, str):
4681            destination = self.parseFormat.parseDecisionSpecifier(
4682                destination
4683            )
4684
4685        destID = graph.getDecision(destination)
4686
4687        newZone: Optional[base.Zone] = base.DefaultZone
4688        here = self.currentDecisionTarget()
4689        newDomain: Optional[base.Domain] = None
4690        if here is not None:
4691            newDomain = graph.domainFor(here)
4692        if self.inRelativeMode:  # create the decision if it didn't exist
4693            if destID not in graph:  # including if it's None
4694                if isinstance(destination, base.DecisionID):
4695                    raise JournalParseError(
4696                        f"Cannot go to decision {destination} because that"
4697                        f" decision ID does not exist, and we cannot create"
4698                        f" a new decision based only on a decision ID. Use"
4699                        f" a DecisionSpecifier or DecisionName to go to a"
4700                        f" new decision that needs to be created."
4701                    )
4702                elif isinstance(destination, base.DecisionName):
4703                    newName = destination
4704                    newZone = base.DefaultZone
4705                elif isinstance(destination, base.DecisionSpecifier):
4706                    specDomain, newZone, newName = destination
4707                    if specDomain is not None:
4708                        newDomain = specDomain
4709                else:
4710                    raise JournalParseError(
4711                        f"Invalid decision specifier: {repr(destination)}."
4712                        f" The destination must be a decision ID, a"
4713                        f" decision name, or a decision specifier."
4714                    )
4715                destID = graph.addDecision(newName, domain=newDomain)
4716                if newZone == base.DefaultZone:
4717                    ctxDecision = self.context['decision']
4718                    if ctxDecision is not None:
4719                        for zp in graph.zoneParents(ctxDecision):
4720                            graph.addDecisionToZone(destID, zp)
4721                elif newZone is not None:
4722                    graph.addDecisionToZone(destID, newZone)
4723                    # TODO: If this zone is new create it & add it to
4724                    # parent zones of old level-0 zone(s)?
4725
4726                base.setExplorationStatus(
4727                    now,
4728                    destID,
4729                    'noticed',
4730                    upgradeOnly=True
4731                )
4732                # TODO: Some way to specify 'hypothesized' here instead?
4733
4734        else:
4735            # in normal mode, 'DiscreteExploration.warp' takes care of
4736            # creating the decision if needed
4737            whichFocus = None
4738            if self.context['focus'] is not None:
4739                whichFocus = (
4740                    self.context['context'],
4741                    self.context['domain'],
4742                    self.context['focus']
4743                )
4744            if destination is None:
4745                destination = destID
4746
4747            if isinstance(destination, base.DecisionSpecifier):
4748                newZone = destination.zone
4749                if destination.domain is not None:
4750                    newDomain = destination.domain
4751            else:
4752                newZone = base.DefaultZone
4753
4754            destID = self.exploration.warp(
4755                destination,
4756                domain=newDomain,
4757                zone=newZone,
4758                whichFocus=whichFocus,
4759                inCommon=self.context['context'] == 'common',
4760                decisionType=decisionType
4761            )
4762            self.autoFinalizeExplorationStatuses()
4763
4764        self.context['decision'] = destID
4765        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:
4767    def recordWait(
4768        self,
4769        decisionType: base.DecisionType = 'active'
4770    ) -> None:
4771        """
4772        Records a wait step. Does not modify the current transition.
4773        A non-standard decision type may be specified.
4774
4775        Raises a `JournalParseError` in relative mode, since it wouldn't
4776        have any effect.
4777        """
4778        if self.inRelativeMode:
4779            raise JournalParseError("Can't wait in relative mode.")
4780        else:
4781            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:
4783    def recordObserveEnding(self, name: base.DecisionName) -> None:
4784        """
4785        Records the observation of an action which warps to an ending,
4786        although unlike `recordEnd` we don't use that action yet. This
4787        does NOT update the current decision, although it sets the
4788        current transition to the action it creates.
4789
4790        The action created has the same name as the ending it warps to.
4791
4792        Note that normally, we just warp to endings, so there's no need
4793        to use `recordObserveEnding`. But if there's a player-controlled
4794        option to end the game at a particular node that is noticed
4795        before it's actually taken, this is the right thing to do.
4796
4797        We set up player-initiated ending transitions as actions with a
4798        goto rather than usual transitions because endings exist in a
4799        separate domain, and are active simultaneously with normal
4800        decisions.
4801        """
4802        graph = self.exploration.getSituation().graph
4803        here = self.definiteDecisionTarget()
4804        # Add the ending decision or grab the ID of the existing ending
4805        eID = graph.endingID(name)
4806        # Create action & add goto consequence
4807        graph.addAction(here, name)
4808        graph.setConsequence(here, name, [base.effect(goto=eID)])
4809        # Set the exploration status
4810        self.exploration.setExplorationStatus(
4811            eID,
4812            'noticed',
4813            upgradeOnly=True
4814        )
4815        self.context['transition'] = (here, name)
4816        # TODO: Prevent things like adding unexplored nodes to the
4817        # 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:
4819    def recordEnd(
4820        self,
4821        name: base.DecisionName,
4822        voluntary: bool = False,
4823        decisionType: Optional[base.DecisionType] = None
4824    ) -> None:
4825        """
4826        Records an ending. If `voluntary` is `False` (the default) then
4827        this becomes a warp that activates the specified ending (which
4828        is in the `core.ENDINGS_DOMAIN` domain, so that doesn't leave
4829        the current decision).
4830
4831        If `voluntary` is `True` then we also record an action with a
4832        'goto' effect that activates the specified ending, and record an
4833        exploration step that takes that action, instead of just a warp
4834        (`recordObserveEnding` would set up such an action without
4835        taking it).
4836
4837        The specified ending decision is created if it didn't already
4838        exist. If `voluntary` is True and an action that warps to the
4839        specified ending already exists with the correct name, we will
4840        simply take that action.
4841
4842        If it created an action, it sets the current transition to the
4843        action that warps to the ending. Endings are not added to zones;
4844        otherwise it sets the current transition to None.
4845
4846        In relative mode, an ending is still added, possibly with an
4847        action that warps to it, and the current decision is set to that
4848        ending node, but the transition doesn't actually get taken.
4849
4850        If not in relative mode, sets the exploration status of the
4851        current decision to `explored` if it wasn't in the
4852        `dontFinalize` set, even though we do not deactivate that
4853        transition.
4854
4855        When `voluntary` is not set, the decision type for the warp will
4856        be 'imposed', otherwise it will be 'active'. However, if an
4857        explicit `decisionType` is specified, that will override these
4858        defaults.
4859        """
4860        graph = self.exploration.getSituation().graph
4861        here = self.definiteDecisionTarget()
4862
4863        # Add our warping action if we need to
4864        if voluntary:
4865            # If voluntary, check for an existing warp action and set
4866            # one up if we don't have one.
4867            aDest = graph.getDestination(here, name)
4868            eID = graph.getDecision(
4869                base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name)
4870            )
4871            if aDest is None:
4872                # Okay we can just create the action
4873                self.recordObserveEnding(name)
4874                # else check if the existing transition is an action
4875                # that warps to the correct ending already
4876            elif (
4877                aDest != here
4878             or eID is None
4879             or not any(
4880                    c == base.effect(goto=eID)
4881                    for c in graph.getConsequence(here, name)
4882                )
4883            ):
4884                raise JournalParseError(
4885                    f"Attempting to add voluntary ending {name!r} at"
4886                    f" decision {graph.identityOf(here)} but that"
4887                    f" decision already has an action with that name"
4888                    f" and it's not set up to warp to that ending"
4889                    f" already."
4890                )
4891
4892        # Grab ending ID (creates the decision if necessary)
4893        eID = graph.endingID(name)
4894
4895        # Update our context variables
4896        self.context['decision'] = eID
4897        if voluntary:
4898            self.context['transition'] = (here, name)
4899        else:
4900            self.context['transition'] = None
4901
4902        # Update exploration status in relative mode, or possibly take
4903        # action in normal mode
4904        if self.inRelativeMode:
4905            self.exploration.setExplorationStatus(
4906                eID,
4907                "noticed",
4908                upgradeOnly=True
4909            )
4910        else:
4911            # Either take the action we added above, or just warp
4912            if decisionType is None:
4913                decisionType = 'active' if voluntary else 'imposed'
4914            decisionType = cast(base.DecisionType, decisionType)
4915
4916            if voluntary:
4917                # Taking the action warps us to the ending
4918                self.exploration.takeAction(
4919                    name,
4920                    decisionType=decisionType
4921                )
4922            else:
4923                # We'll use a warp to get there
4924                self.exploration.warp(
4925                    base.DecisionSpecifier(core.ENDINGS_DOMAIN, None, name),
4926                    zone=None,
4927                    decisionType=decisionType
4928                )
4929                if (
4930                    here not in self.dontFinalize
4931                and (
4932                        self.exploration.getExplorationStatus(here)
4933                     == "exploring"
4934                    )
4935                ):
4936                    self.exploration.setExplorationStatus(here, "explored")
4937        # TODO: Prevent things like adding unexplored nodes to the
4938        # 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:
4940    def recordMechanism(
4941        self,
4942        where: Optional[base.AnyDecisionSpecifier],
4943        name: base.MechanismName,
4944        startingState: base.MechanismState = base.DEFAULT_MECHANISM_STATE
4945    ) -> None:
4946        """
4947        Records the existence of a mechanism at the specified decision
4948        with the specified starting state (or the default starting
4949        state). Set `where` to `None` to set up a global mechanism that's
4950        not tied to any particular decision.
4951        """
4952        graph = self.exploration.getSituation().graph
4953        # TODO: a way to set up global mechanisms
4954        newID = graph.addMechanism(name, where)
4955        if startingState != base.DEFAULT_MECHANISM_STATE:
4956            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:
4958    def recordRequirement(self, req: Union[base.Requirement, str]) -> None:
4959        """
4960        Records a requirement observed on the most recently
4961        defined/taken transition. If a string is given,
4962        `ParseFormat.parseRequirement` will be used to parse it.
4963        """
4964        if isinstance(req, str):
4965            req = self.parseFormat.parseRequirement(req)
4966        target = self.currentTransitionTarget()
4967        if target is None:
4968            raise JournalParseError(
4969                "Can't set a requirement because there is no current"
4970                " transition."
4971            )
4972        graph = self.exploration.getSituation().graph
4973        graph.setTransitionRequirement(
4974            *target,
4975            req
4976        )

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:
4978    def recordReciprocalRequirement(
4979        self,
4980        req: Union[base.Requirement, str]
4981    ) -> None:
4982        """
4983        Records a requirement observed on the reciprocal of the most
4984        recently defined/taken transition. If a string is given,
4985        `ParseFormat.parseRequirement` will be used to parse it.
4986        """
4987        if isinstance(req, str):
4988            req = self.parseFormat.parseRequirement(req)
4989        target = self.currentReciprocalTarget()
4990        if target is None:
4991            raise JournalParseError(
4992                "Can't set a reciprocal requirement because there is no"
4993                " current transition or it doesn't have a reciprocal."
4994            )
4995        graph = self.exploration.getSituation().graph
4996        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:
4998    def recordTransitionConsequence(
4999        self,
5000        consequence: base.Consequence
5001    ) -> None:
5002        """
5003        Records a transition consequence, which gets added to any
5004        existing consequences of the currently-relevant transition (the
5005        most-recently created or taken transition). A `JournalParseError`
5006        will be raised if there is no current transition.
5007        """
5008        target = self.currentTransitionTarget()
5009        if target is None:
5010            raise JournalParseError(
5011                "Cannot apply a consequence because there is no current"
5012                " transition."
5013            )
5014
5015        now = self.exploration.getSituation()
5016        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:
5018    def recordReciprocalConsequence(
5019        self,
5020        consequence: base.Consequence
5021    ) -> None:
5022        """
5023        Like `recordTransitionConsequence` but applies the effect to the
5024        reciprocal of the current transition. Will cause a
5025        `JournalParseError` if the current transition has no reciprocal
5026        (e.g., it's an ending transition).
5027        """
5028        target = self.currentReciprocalTarget()
5029        if target is None:
5030            raise JournalParseError(
5031                "Cannot apply a reciprocal effect because there is no"
5032                " current transition, or it doesn't have a reciprocal."
5033            )
5034
5035        now = self.exploration.getSituation()
5036        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:
5038    def recordAdditionalTransitionConsequence(
5039        self,
5040        consequence: base.Consequence,
5041        hideEffects: bool = True
5042    ) -> None:
5043        """
5044        Records the addition of a new consequence to the current
5045        relevant transition, while also triggering the effects of that
5046        consequence (but not the other effects of that transition, which
5047        we presume have just been applied already).
5048
5049        By default each effect added this way automatically gets the
5050        "hidden" property added to it, because the assumption is if it
5051        were a foreseeable effect, you would have added it to the
5052        transition before taking it. If you set `hideEffects` to
5053        `False`, this won't be done.
5054
5055        This modifies the current state but does not add a step to the
5056        exploration. It does NOT call `autoFinalizeExplorationStatuses`,
5057        which means that if a 'bounce' or 'goto' effect ends up making
5058        one or more decisions no-longer-active, they do NOT get their
5059        exploration statuses upgraded to 'explored'.
5060        """
5061        # Receive begin/end indices from `addConsequence` and send them
5062        # to `applyTransitionConsequence` to limit which # parts of the
5063        # expanded consequence are actually applied.
5064        currentTransition = self.currentTransitionTarget()
5065        if currentTransition is None:
5066            consRepr = self.parseFormat.unparseConsequence(consequence)
5067            raise JournalParseError(
5068                f"Can't apply an additional consequence to a transition"
5069                f" when there is no current transition. Got"
5070                f" consequence:\n{consRepr}"
5071            )
5072
5073        if hideEffects:
5074            for (index, item) in base.walkParts(consequence):
5075                if isinstance(item, dict) and 'value' in item:
5076                    assert 'hidden' in item
5077                    item = cast(base.Effect, item)
5078                    item['hidden'] = True
5079
5080        now = self.exploration.getSituation()
5081        begin, end = now.graph.addConsequence(
5082            *currentTransition,
5083            consequence
5084        )
5085        self.exploration.applyTransitionConsequence(
5086            *currentTransition,
5087            moveWhich=self.context['focus'],
5088            policy="specified",
5089            fromIndex=begin,
5090            toIndex=end
5091        )
5092        # This tracks trigger counts and obeys
5093        # charges/delays, unlike
5094        # applyExtraneousConsequence, but some effects
5095        # 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:
5097    def recordTagStep(
5098        self,
5099        tag: base.Tag,
5100        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5101    ) -> None:
5102        """
5103        Records a tag to be applied to the current exploration step.
5104        """
5105        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:
5107    def recordTagDecision(
5108        self,
5109        tag: base.Tag,
5110        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5111    ) -> None:
5112        """
5113        Records a tag to be applied to the current decision.
5114        """
5115        now = self.exploration.getSituation()
5116        now.graph.tagDecision(
5117            self.definiteDecisionTarget(),
5118            tag,
5119            value
5120        )

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:
5122    def recordTagTranstion(
5123        self,
5124        tag: base.Tag,
5125        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5126    ) -> None:
5127        """
5128        Records a tag to be applied to the most-recently-defined or
5129        -taken transition.
5130        """
5131        target = self.currentTransitionTarget()
5132        if target is None:
5133            raise JournalParseError(
5134                "Cannot tag a transition because there is no current"
5135                " transition."
5136            )
5137
5138        now = self.exploration.getSituation()
5139        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:
5141    def recordTagReciprocal(
5142        self,
5143        tag: base.Tag,
5144        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5145    ) -> None:
5146        """
5147        Records a tag to be applied to the reciprocal of the
5148        most-recently-defined or -taken transition.
5149        """
5150        target = self.currentReciprocalTarget()
5151        if target is None:
5152            raise JournalParseError(
5153                "Cannot tag a transition because there is no current"
5154                " transition."
5155            )
5156
5157        now = self.exploration.getSituation()
5158        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:
5160    def currentZoneAtLevel(self, level: int) -> base.Zone:
5161        """
5162        Returns a zone in the current graph that applies to the current
5163        decision which is at the specified hierarchy level. If there is
5164        no such zone, raises a `JournalParseError`. If there are
5165        multiple such zones, returns the zone which includes the fewest
5166        decisions, breaking ties alphabetically by zone name.
5167        """
5168        here = self.definiteDecisionTarget()
5169        graph = self.exploration.getSituation().graph
5170        ancestors = graph.zoneAncestors(here)
5171        candidates = [
5172            ancestor
5173            for ancestor in ancestors
5174            if graph.zoneHierarchyLevel(ancestor) == level
5175        ]
5176        if len(candidates) == 0:
5177            raise JournalParseError(
5178                (
5179                    f"Cannot find any level-{level} zones for the"
5180                    f" current decision {graph.identityOf(here)}. That"
5181                    f" decision is"
5182                ) + (
5183                    " in the following zones:"
5184                  + '\n'.join(
5185                        f"  level {graph.zoneHierarchyLevel(z)}: {z!r}"
5186                        for z in ancestors
5187                    )
5188                ) if len(ancestors) > 0 else (
5189                    " not in any zones."
5190                )
5191            )
5192        candidates.sort(
5193            key=lambda zone: (len(graph.allDecisionsInZone(zone)), zone)
5194        )
5195        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:
5197    def recordTagZone(
5198        self,
5199        level: int,
5200        tag: base.Tag,
5201        value: Union[base.TagValue, type[base.NoTagValue]] = base.NoTagValue
5202    ) -> None:
5203        """
5204        Records a tag to be applied to one of the zones that the current
5205        decision is in, at a specific hierarchy level. There must be at
5206        least one zone ancestor of the current decision at that hierarchy
5207        level; if there are multiple then the tag is applied to the
5208        smallest one, breaking ties by alphabetical order.
5209        """
5210        applyTo = self.currentZoneAtLevel(level)
5211        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:
5213    def recordAnnotateStep(
5214        self,
5215        *annotations: base.Annotation
5216    ) -> None:
5217        """
5218        Records annotations to be applied to the current exploration
5219        step.
5220        """
5221        self.exploration.annotateStep(annotations)
5222        pf = self.parseFormat
5223        now = self.exploration.getSituation()
5224        for a in annotations:
5225            if a.startswith("at:"):
5226                expects = pf.parseDecisionSpecifier(a[3:])
5227                if isinstance(expects, base.DecisionSpecifier):
5228                    if expects.domain is None and expects.zone is None:
5229                        expects = base.spliceDecisionSpecifiers(
5230                            expects,
5231                            self.decisionTargetSpecifier()
5232                        )
5233                eID = now.graph.getDecision(expects)
5234                primaryNow: Optional[base.DecisionID]
5235                if self.inRelativeMode:
5236                    primaryNow = self.definiteDecisionTarget()
5237                else:
5238                    primaryNow = now.state['primaryDecision']
5239                if eID is None:
5240                    self.warn(
5241                        f"'at' annotation expects position {expects!r}"
5242                        f" but that's not a valid decision specifier in"
5243                        f" the current graph."
5244                    )
5245                elif eID != primaryNow:
5246                    self.warn(
5247                        f"'at' annotation expects position {expects!r}"
5248                        f" which is decision"
5249                        f" {now.graph.identityOf(eID)}, but the current"
5250                        f" primary decision is"
5251                        f" {now.graph.identityOf(primaryNow)}"
5252                    )
5253            elif a.startswith("active:"):
5254                expects = pf.parseDecisionSpecifier(a[3:])
5255                eID = now.graph.getDecision(expects)
5256                atNow = base.combinedDecisionSet(now.state)
5257                if eID is None:
5258                    self.warn(
5259                        f"'active' annotation expects decision {expects!r}"
5260                        f" but that's not a valid decision specifier in"
5261                        f" the current graph."
5262                    )
5263                elif eID not in atNow:
5264                    self.warn(
5265                        f"'active' annotation expects decision {expects!r}"
5266                        f" which is {now.graph.identityOf(eID)}, but"
5267                        f" the current active position(s) is/are:"
5268                        f"\n{now.graph.namesListing(atNow)}"
5269                    )
5270            elif a.startswith("has:"):
5271                ea = pf.parseOneEffectArg(pf.lex(a[4:]))[0]
5272                if (
5273                    isinstance(ea, tuple)
5274                and len(ea) == 2
5275                and isinstance(ea[0], base.Token)
5276                and isinstance(ea[1], base.TokenCount)
5277                ):
5278                    countNow = base.combinedTokenCount(now.state, ea[0])
5279                    if countNow != ea[1]:
5280                        self.warn(
5281                            f"'has' annotation expects {ea[1]} {ea[0]!r}"
5282                            f" token(s) but the current state has"
5283                            f" {countNow} of them."
5284                        )
5285                else:
5286                    self.warn(
5287                        f"'has' annotation expects tokens {a[4:]!r} but"
5288                        f" that's not a (token, count) pair."
5289                    )
5290            elif a.startswith("level:"):
5291                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5292                if (
5293                    isinstance(ea, tuple)
5294                and len(ea) == 3
5295                and ea[0] == 'skill'
5296                and isinstance(ea[1], base.Skill)
5297                and isinstance(ea[2], base.Level)
5298                ):
5299                    levelNow = base.getSkillLevel(now.state, ea[1])
5300                    if levelNow != ea[2]:
5301                        self.warn(
5302                            f"'level' annotation expects skill {ea[1]!r}"
5303                            f" to be at level {ea[2]} but the current"
5304                            f" level for that skill is {levelNow}."
5305                        )
5306                else:
5307                    self.warn(
5308                        f"'level' annotation expects skill {a[6:]!r} but"
5309                        f" that's not a (skill, level) pair."
5310                    )
5311            elif a.startswith("can:"):
5312                try:
5313                    req = pf.parseRequirement(a[4:])
5314                except parsing.ParseError:
5315                    self.warn(
5316                        f"'can' annotation expects requirement {a[4:]!r}"
5317                        f" but that's not parsable as a requirement."
5318                    )
5319                    req = None
5320                if req is not None:
5321                    ctx = base.genericContextForSituation(now)
5322                    if not req.satisfied(ctx):
5323                        self.warn(
5324                            f"'can' annotation expects requirement"
5325                            f" {req!r} to be satisfied but it's not in"
5326                            f" the current situation."
5327                        )
5328            elif a.startswith("state:"):
5329                ctx = base.genericContextForSituation(
5330                    now
5331                )
5332                ea = pf.parseOneEffectArg(pf.lex(a[6:]))[0]
5333                if (
5334                    isinstance(ea, tuple)
5335                and len(ea) == 2
5336                and isinstance(ea[0], tuple)
5337                and len(ea[0]) == 4
5338                and (ea[0][0] is None or isinstance(ea[0][0], base.Domain))
5339                and (ea[0][1] is None or isinstance(ea[0][1], base.Zone))
5340                and (
5341                        ea[0][2] is None
5342                     or isinstance(ea[0][2], base.DecisionName)
5343                    )
5344                and isinstance(ea[0][3], base.MechanismName)
5345                and isinstance(ea[1], base.MechanismState)
5346                ):
5347                    mID = now.graph.resolveMechanism(ea[0], ctx.searchFrom)
5348                    stateNow = base.stateOfMechanism(ctx, mID)
5349                    if not base.mechanismInStateOrEquivalent(
5350                        mID,
5351                        ea[1],
5352                        ctx
5353                    ):
5354                        self.warn(
5355                            f"'state' annotation expects mechanism {mID}"
5356                            f" {ea[0]!r} to be in state {ea[1]!r} but"
5357                            f" its current state is {stateNow!r} and no"
5358                            f" equivalence makes it count as being in"
5359                            f" state {ea[1]!r}."
5360                        )
5361                else:
5362                    self.warn(
5363                        f"'state' annotation expects mechanism state"
5364                        f" {a[6:]!r} but that's not a mechanism/state"
5365                        f" pair."
5366                    )
5367            elif a.startswith("exists:"):
5368                expects = pf.parseDecisionSpecifier(a[7:])
5369                try:
5370                    now.graph.resolveDecision(expects)
5371                except core.MissingDecisionError:
5372                    self.warn(
5373                        f"'exists' annotation expects decision"
5374                        f" {a[7:]!r} but that decision does not exist."
5375                    )

Records annotations to be applied to the current exploration step.

def recordAnnotateDecision(self, *annotations: str) -> None:
5377    def recordAnnotateDecision(
5378        self,
5379        *annotations: base.Annotation
5380    ) -> None:
5381        """
5382        Records annotations to be applied to the current decision.
5383        """
5384        now = self.exploration.getSituation()
5385        now.graph.annotateDecision(self.definiteDecisionTarget(), annotations)

Records annotations to be applied to the current decision.

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

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

def recordAnnotateReciprocal(self, *annotations: str) -> None:
5405    def recordAnnotateReciprocal(
5406        self,
5407        *annotations: base.Annotation
5408    ) -> None:
5409        """
5410        Records annotations to be applied to the reciprocal of the
5411        most-recently-defined or -taken transition.
5412        """
5413        target = self.currentReciprocalTarget()
5414        if target is None:
5415            raise JournalParseError(
5416                "Cannot annotate a reciprocal because there is no"
5417                " current transition or because it doens't have a"
5418                " reciprocal."
5419            )
5420
5421        now = self.exploration.getSituation()
5422        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:
5424    def recordAnnotateZone(
5425        self,
5426        level,
5427        *annotations: base.Annotation
5428    ) -> None:
5429        """
5430        Records annotations to be applied to the zone at the specified
5431        hierarchy level which contains the current decision. If there are
5432        multiple such zones, it picks the smallest one, breaking ties
5433        alphabetically by zone name (see `currentZoneAtLevel`).
5434        """
5435        applyTo = self.currentZoneAtLevel(level)
5436        self.exploration.getSituation().graph.annotateZone(
5437            applyTo,
5438            annotations
5439        )

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:
5441    def recordContextSwap(
5442        self,
5443        targetContext: Optional[base.FocalContextName]
5444    ) -> None:
5445        """
5446        Records a swap of the active focal context, and/or a swap into
5447        "common"-context mode where all effects modify the common focal
5448        context instead of the active one. Use `None` as the argument to
5449        swap to common mode; use another specific value so swap to
5450        normal mode and set that context as the active one.
5451
5452        In relative mode, swaps the active context without adding an
5453        exploration step. Swapping into the common context never results
5454        in a new exploration step.
5455        """
5456        if targetContext is None:
5457            self.context['context'] = "common"
5458        else:
5459            self.context['context'] = "active"
5460            e = self.getExploration()
5461            if self.inRelativeMode:
5462                e.setActiveContext(targetContext)
5463            else:
5464                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:
5466    def recordZone(self, level: int, zone: base.Zone) -> None:
5467        """
5468        Records a new current zone to be swapped with the zone(s) at the
5469        specified hierarchy level for the current decision target. See
5470        `core.DiscreteExploration.reZone` and
5471        `core.DecisionGraph.replaceZonesInHierarchy` for details on what
5472        exactly happens; the summary is that the zones at the specified
5473        hierarchy level are replaced with the provided zone (which is
5474        created if necessary) and their children are re-parented onto
5475        the provided zone, while that zone is also set as a child of
5476        their parents.
5477
5478        Does the same thing in relative mode as in normal mode.
5479        """
5480        self.exploration.reZone(
5481            zone,
5482            self.definiteDecisionTarget(),
5483            level
5484        )

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:
5486    def recordUnify(
5487        self,
5488        merge: base.AnyDecisionSpecifier,
5489        mergeInto: Optional[base.AnyDecisionSpecifier] = None
5490    ) -> None:
5491        """
5492        Records a unification between two decisions. This marks an
5493        observation that they are actually the same decision and it
5494        merges them. If only one decision is given the current decision
5495        is merged into that one. After the merge, the first decision (or
5496        the current decision if only one was given) will no longer
5497        exist.
5498
5499        If one of the merged decisions was the current position in a
5500        singular-focalized domain, or one of the current positions in a
5501        plural- or spreading-focalized domain, the merged decision will
5502        replace it as a current decision after the merge, and this
5503        happens even when in relative mode. The target decision is also
5504        updated if it needs to be.
5505
5506        A `TransitionCollisionError` will be raised if the two decisions
5507        have outgoing transitions that share a name.
5508
5509        Logs a `JournalParseWarning` if the two decisions were in
5510        different zones.
5511
5512        Any transitions between the two merged decisions will remain in
5513        place as actions.
5514
5515        TODO: Option for removing self-edges after the merge? Option for
5516        doing that for just effect-less edges?
5517        """
5518        if mergeInto is None:
5519            mergeInto = merge
5520            merge = self.definiteDecisionTarget()
5521
5522        if isinstance(merge, str):
5523            merge = self.parseFormat.parseDecisionSpecifier(merge)
5524
5525        if isinstance(mergeInto, str):
5526            mergeInto = self.parseFormat.parseDecisionSpecifier(mergeInto)
5527
5528        now = self.exploration.getSituation()
5529
5530        if not isinstance(merge, base.DecisionID):
5531            merge = now.graph.resolveDecision(merge)
5532
5533        merge = cast(base.DecisionID, merge)
5534
5535        now.graph.mergeDecisions(merge, mergeInto)
5536
5537        mergedID = now.graph.resolveDecision(mergeInto)
5538
5539        # Update FocalContexts & ObservationContexts as necessary
5540        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:
5542    def recordUnifyTransition(self, target: base.Transition) -> None:
5543        """
5544        Records a unification between the most-recently-defined or
5545        -taken transition and the specified transition (which must be
5546        outgoing from the same decision). This marks an observation that
5547        two transitions are actually the same transition and it merges
5548        them.
5549
5550        After the merge, the target transition will still exist but the
5551        previously most-recent transition will have been deleted.
5552
5553        Their reciprocals will also be merged.
5554
5555        A `JournalParseError` is raised if there is no most-recent
5556        transition.
5557        """
5558        now = self.exploration.getSituation()
5559        graph = now.graph
5560        affected = self.currentTransitionTarget()
5561        if affected is None or affected[1] is None:
5562            raise JournalParseError(
5563                "Cannot unify transitions: there is no current"
5564                " transition."
5565            )
5566
5567        decision, transition = affected
5568
5569        # If they don't share a target, then the current transition must
5570        # lead to an unknown node, which we will dispose of
5571        destination = graph.getDestination(decision, transition)
5572        if destination is None:
5573            raise JournalParseError(
5574                f"Cannot unify transitions: transition"
5575                f" {transition!r} at decision"
5576                f" {graph.identityOf(decision)} has no destination."
5577            )
5578
5579        finalDestination = graph.getDestination(decision, target)
5580        if finalDestination is None:
5581            raise JournalParseError(
5582                f"Cannot unify transitions: transition"
5583                f" {target!r} at decision {graph.identityOf(decision)}"
5584                f" has no destination."
5585            )
5586
5587        if destination != finalDestination:
5588            if graph.isConfirmed(destination):
5589                raise JournalParseError(
5590                    f"Cannot unify transitions: destination"
5591                    f" {graph.identityOf(destination)} of transition"
5592                    f" {transition!r} at decision"
5593                    f" {graph.identityOf(decision)} is not an"
5594                    f" unconfirmed decision."
5595                )
5596            # Retarget and delete the unknown node that we abandon
5597            # TODO: Merge nodes instead?
5598            now.graph.retargetTransition(
5599                decision,
5600                transition,
5601                finalDestination
5602            )
5603            now.graph.removeDecision(destination)
5604
5605        # Now we can merge transitions
5606        now.graph.mergeTransitions(decision, transition, target)
5607
5608        # Update targets if they were merged
5609        self.cleanupContexts(
5610            remappedTransitions={
5611                (decision, transition): (decision, target)
5612            }
5613        )

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

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:
5688    def recordObviate(
5689        self,
5690        transition: base.Transition,
5691        otherDecision: base.AnyDecisionSpecifier,
5692        otherTransition: base.Transition
5693    ) -> None:
5694        """
5695        Records the obviation of a transition at another decision. This
5696        is the observation that a specific transition at the current
5697        decision is the reciprocal of a different transition at another
5698        decision which previously led to an unknown area. The difference
5699        between this and `recordReturn` is that `recordReturn` logs
5700        movement across the newly-connected transition, while this
5701        leaves the player at their original decision (and does not even
5702        add a step to the current exploration).
5703
5704        Both transitions will be created if they didn't already exist.
5705
5706        In relative mode does the same thing and doesn't move the current
5707        decision across the transition updated.
5708
5709        If the destination is unknown, it will remain unknown after this
5710        operation.
5711        """
5712        now = self.exploration.getSituation()
5713        graph = now.graph
5714        here = self.definiteDecisionTarget()
5715
5716        if isinstance(otherDecision, str):
5717            otherDecision = self.parseFormat.parseDecisionSpecifier(
5718                otherDecision
5719            )
5720
5721        # If we started with a name or some other kind of decision
5722        # specifier, replace missing domain and/or zone info with info
5723        # from the current decision.
5724        if isinstance(otherDecision, base.DecisionSpecifier):
5725            otherDecision = base.spliceDecisionSpecifiers(
5726                otherDecision,
5727                self.decisionTargetSpecifier()
5728            )
5729
5730        otherDestination = graph.getDestination(
5731            otherDecision,
5732            otherTransition
5733        )
5734        if otherDestination is not None:
5735            if graph.isConfirmed(otherDestination):
5736                raise JournalParseError(
5737                    f"Cannot obviate transition {otherTransition!r} at"
5738                    f" decision {graph.identityOf(otherDecision)}: that"
5739                    f" transition leads to decision"
5740                    f" {graph.identityOf(otherDestination)} which has"
5741                    f" already been visited."
5742                )
5743        else:
5744            # We must create the other destination
5745            graph.addUnexploredEdge(otherDecision, otherTransition)
5746
5747        destination = graph.getDestination(here, transition)
5748        if destination is not None:
5749            if graph.isConfirmed(destination):
5750                raise JournalParseError(
5751                    f"Cannot obviate using transition {transition!r} at"
5752                    f" decision {graph.identityOf(here)}: that"
5753                    f" transition leads to decision"
5754                    f" {graph.identityOf(destination)} which is not an"
5755                    f" unconfirmed decision."
5756                )
5757        else:
5758            # we need to create it
5759            graph.addUnexploredEdge(here, transition)
5760
5761        # Track exploration status of destination (because
5762        # `replaceUnconfirmed` will overwrite it but we want to preserve
5763        # it in this case.
5764        if otherDecision is not None:
5765            prevStatus = base.explorationStatusOf(now, otherDecision)
5766
5767        # Now connect the transitions and clean up the unknown nodes
5768        graph.replaceUnconfirmed(
5769            here,
5770            transition,
5771            otherDecision,
5772            otherTransition
5773        )
5774        # Restore exploration status
5775        base.setExplorationStatus(now, otherDecision, prevStatus)
5776
5777        # Update context
5778        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:
5780    def cleanupContexts(
5781        self,
5782        remapped: Optional[Dict[base.DecisionID, base.DecisionID]] = None,
5783        remappedTransitions: Optional[
5784            Dict[
5785                Tuple[base.DecisionID, base.Transition],
5786                Tuple[base.DecisionID, base.Transition]
5787            ]
5788        ] = None
5789    ) -> None:
5790        """
5791        Checks the validity of context decision and transition entries,
5792        and sets them to `None` in situations where they are no longer
5793        valid, affecting both the current and stored contexts.
5794
5795        Also updates position information in focal contexts in the
5796        current exploration step.
5797
5798        If a `remapped` dictionary is provided, decisions in the keys of
5799        that dictionary will be replaced with the corresponding value
5800        before being checked.
5801
5802        Similarly a `remappedTransitions` dicitonary may provide info on
5803        renamed transitions using (`base.DecisionID`, `base.Transition`)
5804        pairs as both keys and values.
5805        """
5806        if remapped is None:
5807            remapped = {}
5808
5809        if remappedTransitions is None:
5810            remappedTransitions = {}
5811
5812        # Fix broken position information in the current focal contexts
5813        now = self.exploration.getSituation()
5814        graph = now.graph
5815        state = now.state
5816        for ctx in (
5817            state['common'],
5818            state['contexts'][state['activeContext']]
5819        ):
5820            active = ctx['activeDecisions']
5821            for domain in active:
5822                aVal = active[domain]
5823                if isinstance(aVal, base.DecisionID):
5824                    if aVal in remapped:  # check for remap
5825                        aVal = remapped[aVal]
5826                        active[domain] = aVal
5827                    if graph.getDecision(aVal) is None: # Ultimately valid?
5828                        active[domain] = None
5829                elif isinstance(aVal, dict):
5830                    for fpName in aVal:
5831                        fpVal = aVal[fpName]
5832                        if fpVal is None:
5833                            aVal[fpName] = None
5834                        elif fpVal in remapped:  # check for remap
5835                            aVal[fpName] = remapped[fpVal]
5836                        elif graph.getDecision(fpVal) is None:  # valid?
5837                            aVal[fpName] = None
5838                elif isinstance(aVal, set):
5839                    for r in remapped:
5840                        if r in aVal:
5841                            aVal.remove(r)
5842                            aVal.add(remapped[r])
5843                    discard = []
5844                    for dID in aVal:
5845                        if graph.getDecision(dID) is None:
5846                            discard.append(dID)
5847                    for dID in discard:
5848                        aVal.remove(dID)
5849                elif aVal is not None:
5850                    raise RuntimeError(
5851                        f"Invalid active decisions for domain"
5852                        f" {repr(domain)}: {repr(aVal)}"
5853                    )
5854
5855        # Fix up our ObservationContexts
5856        fix = [self.context]
5857        if self.storedContext is not None:
5858            fix.append(self.storedContext)
5859
5860        graph = self.exploration.getSituation().graph
5861        for obsCtx in fix:
5862            cdID = obsCtx['decision']
5863            if cdID in remapped:
5864                cdID = remapped[cdID]
5865                obsCtx['decision'] = cdID
5866
5867            if cdID not in graph:
5868                obsCtx['decision'] = None
5869
5870            transition = obsCtx['transition']
5871            if transition is not None:
5872                tSourceID = transition[0]
5873                if tSourceID in remapped:
5874                    tSourceID = remapped[tSourceID]
5875                    obsCtx['transition'] = (tSourceID, transition[1])
5876
5877                if transition in remappedTransitions:
5878                    obsCtx['transition'] = remappedTransitions[transition]
5879
5880                tDestID = graph.getDestination(tSourceID, transition[1])
5881                if tDestID is None:
5882                    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:
5884    def recordExtinguishDecision(
5885        self,
5886        target: base.AnyDecisionSpecifier
5887    ) -> None:
5888        """
5889        Records the deletion of a decision. The decision and all
5890        transitions connected to it will be removed from the current
5891        graph. Does not create a new exploration step. If the current
5892        position is deleted, the position will be set to `None`, or if
5893        we're in relative mode, the decision target will be set to
5894        `None` if it gets deleted. Likewise, all stored and/or current
5895        transitions which no longer exist are erased to `None`.
5896        """
5897        # Erase target if it's going to be removed
5898        now = self.exploration.getSituation()
5899
5900        if isinstance(target, str):
5901            target = self.parseFormat.parseDecisionSpecifier(target)
5902
5903        # TODO: Do we need to worry about the node being part of any
5904        # focal context data structures?
5905
5906        # Actually remove it
5907        now.graph.removeDecision(target)
5908
5909        # Clean up our contexts
5910        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:
5912    def recordExtinguishTransition(
5913        self,
5914        source: base.AnyDecisionSpecifier,
5915        target: base.Transition,
5916        deleteReciprocal: bool = True
5917    ) -> None:
5918        """
5919        Records the deletion of a named transition coming from a
5920        specific source. The reciprocal will also be removed, unless
5921        `deleteReciprocal` is set to False. If `deleteReciprocal` is
5922        used and this results in the complete isolation of an unknown
5923        node, that node will be deleted as well. Cleans up any saved
5924        transition targets that are no longer valid by setting them to
5925        `None`. Does not create a graph step.
5926        """
5927        now = self.exploration.getSituation()
5928        graph = now.graph
5929        dest = graph.destination(source, target)
5930
5931        # Remove the transition
5932        graph.removeTransition(source, target, deleteReciprocal)
5933
5934        # Remove the old destination if it's unconfirmed and no longer
5935        # connected anywhere
5936        if (
5937            not graph.isConfirmed(dest)
5938        and len(graph.destinationsFrom(dest)) == 0
5939        ):
5940            graph.removeDecision(dest)
5941
5942        # Clean up our contexts
5943        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:
5945    def recordComplicate(
5946        self,
5947        target: base.Transition,
5948        newDecision: base.DecisionName,  # TODO: Allow zones/domain here
5949        newReciprocal: Optional[base.Transition],
5950        newReciprocalReciprocal: Optional[base.Transition]
5951    ) -> base.DecisionID:
5952        """
5953        Records the complication of a transition and its reciprocal into
5954        a new decision. The old transition and its old reciprocal (if
5955        there was one) both point to the new decision. The
5956        `newReciprocal` becomes the new reciprocal of the original
5957        transition, and the `newReciprocalReciprocal` becomes the new
5958        reciprocal of the old reciprocal. Either may be set explicitly to
5959        `None` to leave the corresponding new transition without a
5960        reciprocal (but they don't default to `None`). If there was no
5961        old reciprocal, but `newReciprocalReciprocal` is specified, then
5962        that transition is created linking the new node to the old
5963        destination, without a reciprocal.
5964
5965        The current decision & transition information is not updated.
5966
5967        Returns the decision ID for the new node.
5968        """
5969        now = self.exploration.getSituation()
5970        graph = now.graph
5971        here = self.definiteDecisionTarget()
5972        domain = graph.domainFor(here)
5973
5974        oldDest = graph.destination(here, target)
5975        oldReciprocal = graph.getReciprocal(here, target)
5976
5977        # Create the new decision:
5978        newID = graph.addDecision(newDecision, domain=domain)
5979        # Note that the new decision is NOT an unknown decision
5980        # We copy the exploration status from the current decision
5981        self.exploration.setExplorationStatus(
5982            newID,
5983            self.exploration.getExplorationStatus(here)
5984        )
5985        # Copy over zone info
5986        for zp in graph.zoneParents(here):
5987            graph.addDecisionToZone(newID, zp)
5988
5989        # Retarget the transitions
5990        graph.retargetTransition(
5991            here,
5992            target,
5993            newID,
5994            swapReciprocal=False
5995        )
5996        if oldReciprocal is not None:
5997            graph.retargetTransition(
5998                oldDest,
5999                oldReciprocal,
6000                newID,
6001                swapReciprocal=False
6002            )
6003
6004        # Add a new reciprocal edge
6005        if newReciprocal is not None:
6006            graph.addTransition(newID, newReciprocal, here)
6007            graph.setReciprocal(here, target, newReciprocal)
6008
6009        # Add a new double-reciprocal edge (even if there wasn't a
6010        # reciprocal before)
6011        if newReciprocalReciprocal is not None:
6012            graph.addTransition(
6013                newID,
6014                newReciprocalReciprocal,
6015                oldDest
6016            )
6017            if oldReciprocal is not None:
6018                graph.setReciprocal(
6019                    oldDest,
6020                    oldReciprocal,
6021                    newReciprocalReciprocal
6022                )
6023
6024        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:
6026    def recordRevert(
6027        self,
6028        slot: base.SaveSlot,
6029        aspects: Set[str],
6030        decisionType: base.DecisionType = 'active'
6031    ) -> None:
6032        """
6033        Records a reversion to a previous state (possibly for only some
6034        aspects of the current state). See `base.revertedState` for the
6035        allowed values and meanings of strings in the aspects set.
6036        Uses the specified decision type, or 'active' by default.
6037
6038        Reversion counts as an exploration step.
6039
6040        This sets the current decision to the primary decision for the
6041        reverted state (which might be `None` in some cases) and sets
6042        the current transition to None.
6043        """
6044        self.exploration.revert(slot, aspects, decisionType=decisionType)
6045        newPrimary = self.exploration.getSituation().state['primaryDecision']
6046        self.context['decision'] = newPrimary
6047        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:
6049    def recordFulfills(
6050        self,
6051        requirement: Union[str, base.Requirement],
6052        fulfilled: Union[
6053            base.Capability,
6054            Tuple[base.MechanismID, base.MechanismState]
6055        ]
6056    ) -> None:
6057        """
6058        Records the observation that a certain requirement fulfills the
6059        same role as (i.e., is equivalent to) a specific capability, or a
6060        specific mechanism being in a specific state. Transitions that
6061        require that capability or mechanism state will count as
6062        traversable even if that capability is not obtained or that
6063        mechanism is in another state, as long as the requirement for the
6064        fulfillment is satisfied. If multiple equivalences are
6065        established, any one of them being satisfied will count as that
6066        capability being obtained (or the mechanism being in the
6067        specified state). Note that if a circular dependency is created,
6068        the capability or mechanism (unless actually obtained or in the
6069        target state) will be considered as not being obtained (or in the
6070        target state) during recursive checks.
6071        """
6072        if isinstance(requirement, str):
6073            requirement = self.parseFormat.parseRequirement(requirement)
6074
6075        self.getExploration().getSituation().graph.addEquivalence(
6076            requirement,
6077            fulfilled
6078        )

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):
6080    def recordFocusOn(
6081        self,
6082        newFocalPoint: base.FocalPointName,
6083        inDomain: Optional[base.Domain] = None,
6084        inCommon: bool = False
6085    ):
6086        """
6087        Records a swap to a new focal point, setting that focal point as
6088        the active focal point in the observer's current domain, or in
6089        the specified domain if one is specified.
6090
6091        A `JournalParseError` is raised if the current/specified domain
6092        does not have plural focalization. If it doesn't have a focal
6093        point with that name, then one is created and positioned at the
6094        observer's current decision (which must be in the appropriate
6095        domain).
6096
6097        If `inCommon` is set to `True` (default is `False`) then the
6098        changes will be applied to the common context instead of the
6099        active context.
6100
6101        Note that this does *not* make the target domain active; use
6102        `recordDomainFocus` for that if you need to.
6103        """
6104        if inDomain is None:
6105            inDomain = self.context['domain']
6106
6107        if inCommon:
6108            ctx = self.getExploration().getCommonContext()
6109        else:
6110            ctx = self.getExploration().getActiveContext()
6111
6112        if ctx['focalization'].get('domain') != 'plural':
6113            raise JournalParseError(
6114                f"Domain {inDomain!r} does not exist or does not have"
6115                f" plural focalization, so we can't set a focal point"
6116                f" in it."
6117            )
6118
6119        focalPointMap = ctx['activeDecisions'].setdefault(inDomain, {})
6120        if not isinstance(focalPointMap, dict):
6121            raise RuntimeError(
6122                f"Plural-focalized domain {inDomain!r} has"
6123                f" non-dictionary active"
6124                f" decisions:\n{repr(focalPointMap)}"
6125            )
6126
6127        if newFocalPoint not in focalPointMap:
6128            focalPointMap[newFocalPoint] = self.context['decision']
6129
6130        self.context['focus'] = newFocalPoint
6131        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):
6133    def recordDomainUnfocus(
6134        self,
6135        domain: base.Domain,
6136        inCommon: bool = False
6137    ):
6138        """
6139        Records a domain losing focus. Does not raise an error if the
6140        target domain was not active (in that case, it doesn't need to
6141        do anything).
6142
6143        If `inCommon` is set to `True` (default is `False`) then the
6144        domain changes will be applied to the common context instead of
6145        the active context.
6146        """
6147        if inCommon:
6148            ctx = self.getExploration().getCommonContext()
6149        else:
6150            ctx = self.getExploration().getActiveContext()
6151
6152        try:
6153            ctx['activeDomains'].remove(domain)
6154        except KeyError:
6155            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):
6157    def recordDomainFocus(
6158        self,
6159        domain: base.Domain,
6160        exclusive: bool = False,
6161        inCommon: bool = False
6162    ):
6163        """
6164        Records a domain gaining focus, activating that domain in the
6165        current focal context and setting it as the observer's current
6166        domain. If the domain named doesn't exist yet, it will be
6167        created first (with default focalization) and then focused.
6168
6169        If `exclusive` is set to `True` (default is `False`) then all
6170        other active domains will be deactivated.
6171
6172        If `inCommon` is set to `True` (default is `False`) then the
6173        domain changes will be applied to the common context instead of
6174        the active context.
6175        """
6176        if inCommon:
6177            ctx = self.getExploration().getCommonContext()
6178        else:
6179            ctx = self.getExploration().getActiveContext()
6180
6181        if exclusive:
6182            ctx['activeDomains'] = set()
6183
6184        if domain not in ctx['focalization']:
6185            self.recordNewDomain(domain, inCommon=inCommon)
6186        else:
6187            ctx['activeDomains'].add(domain)
6188
6189        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):
6191    def recordNewDomain(
6192        self,
6193        domain: base.Domain,
6194        focalization: base.DomainFocalization = "singular",
6195        inCommon: bool = False
6196    ):
6197        """
6198        Records a new domain, setting it up with the specified
6199        focalization. Sets that domain as an active domain and as the
6200        journal's current domain so that subsequent entries will create
6201        decisions in that domain. However, it does not activate any
6202        decisions within that domain.
6203
6204        Raises a `JournalParseError` if the specified domain already
6205        exists.
6206
6207        If `inCommon` is set to `True` (default is `False`) then the new
6208        domain will be made active in the common context instead of the
6209        active context.
6210        """
6211        if inCommon:
6212            ctx = self.getExploration().getCommonContext()
6213        else:
6214            ctx = self.getExploration().getActiveContext()
6215
6216        if domain in ctx['focalization']:
6217            raise JournalParseError(
6218                f"Cannot create domain {domain!r}: that domain already"
6219                f" exists."
6220            )
6221
6222        ctx['focalization'][domain] = focalization
6223        ctx['activeDecisions'][domain] = None
6224        ctx['activeDomains'].add(domain)
6225        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:
6227    def relative(
6228        self,
6229        where: Optional[base.AnyDecisionSpecifier] = None,
6230        transition: Optional[base.Transition] = None,
6231    ) -> None:
6232        """
6233        Enters 'relative mode' where the exploration ceases to add new
6234        steps but edits can still be performed on the current graph. This
6235        also changes the current decision/transition settings so that
6236        edits can be applied anywhere. It can accept 0, 1, or 2
6237        arguments. With 0 arguments, it simply enters relative mode but
6238        maintains the current position as the target decision and the
6239        last-taken or last-created transition as the target transition
6240        (note that that transition usually originates at a different
6241        decision). With 1 argument, it sets the target decision to the
6242        decision named, and sets the target transition to None. With 2
6243        arguments, it sets the target decision to the decision named, and
6244        the target transition to the transition named, which must
6245        originate at that target decision. If the first argument is None,
6246        the current decision is used.
6247
6248        If given the name of a decision which does not yet exist, it will
6249        create that decision in the current graph, disconnected from the
6250        rest of the graph. In that case, it is an error to also supply a
6251        transition to target (you can use other commands once in relative
6252        mode to build more transitions and decisions out from the
6253        newly-created decision).
6254
6255        When called in relative mode, it updates the current position
6256        and/or decision, or if called with no arguments, it exits
6257        relative mode. When exiting relative mode, the current decision
6258        is set back to the graph's current position, and the current
6259        transition is set to whatever it was before relative mode was
6260        entered.
6261
6262        Raises a `TypeError` if a transition is specified without
6263        specifying a decision. Raises a `ValueError` if given no
6264        arguments and the exploration does not have a current position.
6265        Also raises a `ValueError` if told to target a specific
6266        transition which does not exist.
6267
6268        TODO: Example here!
6269        """
6270        # TODO: Not this?
6271        if where is None:
6272            if transition is None and self.inRelativeMode:
6273                # If we're in relative mode, cancel it
6274                self.inRelativeMode = False
6275
6276                # Here we restore saved sate
6277                if self.storedContext is None:
6278                    raise RuntimeError(
6279                        "No stored context despite being in relative"
6280                        "mode."
6281                    )
6282                self.context = self.storedContext
6283                self.storedContext = None
6284
6285            else:
6286                # Enter or stay in relative mode and set up the current
6287                # decision/transition as the targets
6288
6289                # Ensure relative mode
6290                self.inRelativeMode = True
6291
6292                # Store state
6293                self.storedContext = self.context
6294                where = self.storedContext['decision']
6295                if where is None:
6296                    raise ValueError(
6297                        "Cannot enter relative mode at the current"
6298                        " position because there is no current"
6299                        " position."
6300                    )
6301
6302                self.context = observationContext(
6303                    context=self.storedContext['context'],
6304                    domain=self.storedContext['domain'],
6305                    focus=self.storedContext['focus'],
6306                    decision=where,
6307                    transition=(
6308                        None
6309                        if transition is None
6310                        else (where, transition)
6311                    )
6312                )
6313
6314        else: # we have at least a decision to target
6315            # If we're entering relative mode instead of just changing
6316            # focus, we need to set up the current transition if no
6317            # transition was specified.
6318            entering: Optional[
6319                Tuple[
6320                    base.ContextSpecifier,
6321                    base.Domain,
6322                    Optional[base.FocalPointName]
6323                ]
6324            ] = None
6325            if not self.inRelativeMode:
6326                # We'll be entering relative mode, so store state
6327                entering = (
6328                    self.context['context'],
6329                    self.context['domain'],
6330                    self.context['focus']
6331                )
6332                self.storedContext = self.context
6333                if transition is None:
6334                    oldTransitionPair = self.context['transition']
6335                    if oldTransitionPair is not None:
6336                        oldBase, oldTransition = oldTransitionPair
6337                        if oldBase == where:
6338                            transition = oldTransition
6339
6340            # Enter (or stay in) relative mode
6341            self.inRelativeMode = True
6342
6343            now = self.exploration.getSituation()
6344            whereID: Optional[base.DecisionID]
6345            whereSpec: Optional[base.DecisionSpecifier] = None
6346            if isinstance(where, str):
6347                where = self.parseFormat.parseDecisionSpecifier(where)
6348                # might turn it into a DecisionID
6349
6350            if isinstance(where, base.DecisionID):
6351                whereID = where
6352            elif isinstance(where, base.DecisionSpecifier):
6353                # Add in current zone + domain info if those things
6354                # aren't explicit
6355                if self.currentDecisionTarget() is not None:
6356                    where = base.spliceDecisionSpecifiers(
6357                        where,
6358                        self.decisionTargetSpecifier()
6359                    )
6360                elif where.domain is None:
6361                    # Splice in current domain if needed
6362                    where = base.DecisionSpecifier(
6363                        domain=self.context['domain'],
6364                        zone=where.zone,
6365                        name=where.name
6366                    )
6367                whereID = now.graph.getDecision(where)  # might be None
6368                whereSpec = where
6369            else:
6370                raise TypeError(f"Invalid decision specifier: {where!r}")
6371
6372            # Create a new decision if necessary
6373            if whereID is None:
6374                if transition is not None:
6375                    raise TypeError(
6376                        f"Cannot specify a target transition when"
6377                        f" entering relative mode at previously"
6378                        f" non-existent decision"
6379                        f" {now.graph.identityOf(where)}."
6380                    )
6381                assert whereSpec is not None
6382                whereID = now.graph.addDecision(
6383                    whereSpec.name,
6384                    domain=whereSpec.domain
6385                )
6386                if whereSpec.zone is not None:
6387                    now.graph.addDecisionToZone(whereID, whereSpec.zone)
6388
6389            # Create the new context if we're entering relative mode
6390            if entering is not None:
6391                self.context = observationContext(
6392                    context=entering[0],
6393                    domain=entering[1],
6394                    focus=entering[2],
6395                    decision=whereID,
6396                    transition=(
6397                        None
6398                        if transition is None
6399                        else (whereID, transition)
6400                    )
6401                )
6402
6403            # Target the specified decision
6404            self.context['decision'] = whereID
6405
6406            # Target the specified transition
6407            if transition is not None:
6408                self.context['transition'] = (whereID, transition)
6409                if now.graph.getDestination(where, transition) is None:
6410                    raise ValueError(
6411                        f"Cannot target transition {transition!r} at"
6412                        f" decision {now.graph.identityOf(where)}:"
6413                        f" there is no such transition."
6414                    )
6415            # 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:
6422def convertJournal(
6423    journal: str,
6424    fmt: Optional[JournalParseFormat] = None
6425) -> core.DiscreteExploration:
6426    """
6427    Converts a journal in text format into a `core.DiscreteExploration`
6428    object, using a fresh `JournalObserver`. An optional `ParseFormat`
6429    may be specified if the journal doesn't follow the default parse
6430    format.
6431    """
6432    obs = JournalObserver(fmt)
6433    obs.observe(journal)
6434    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.